Annotates feed queryset with Max(episodes__pub_date) so feeds are sorted by when their latest episode was published, not when the feed was last fetched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
658 lines
21 KiB
Python
658 lines
21 KiB
Python
import json
|
|
import urllib.parse
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import feedparser
|
|
import requests
|
|
from django.conf import settings
|
|
from django.db.models import Count, F, 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 _refresh_feed(feed_obj):
|
|
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
|
|
parsed = feedparser.parse(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:
|
|
count = len(new_episodes_list)
|
|
PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count)
|
|
for idx, ep_obj in enumerate(new_episodes_list):
|
|
PodcastQueue.objects.get_or_create(
|
|
user=feed_obj.user, episode=ep_obj,
|
|
defaults={'position': idx},
|
|
)
|
|
|
|
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=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)
|
|
|
|
feeds = list(
|
|
request.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 JsonResponse({'feeds': 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})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Queue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@csrf_exempt
|
|
def queue_get(request):
|
|
if not request.user.is_authenticated:
|
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
|
|
items = list(
|
|
PodcastQueue.objects
|
|
.filter(user=request.user)
|
|
.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',
|
|
)
|
|
)
|
|
|
|
ep_ids = [item['episode__id'] for item in items]
|
|
progress_map = {
|
|
p['episode_id']: p['position_seconds']
|
|
for p in EpisodeProgress.objects.filter(
|
|
user=request.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)
|
|
|
|
return JsonResponse({'queue': 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)
|
|
|
|
episode_id = body.get('episode_id')
|
|
try:
|
|
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
|
|
except PodcastEpisode.DoesNotExist:
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
max_pos = PodcastQueue.objects.filter(user=request.user).count()
|
|
_, created = PodcastQueue.objects.get_or_create(
|
|
user=request.user,
|
|
episode=episode,
|
|
defaults={'position': max_pos},
|
|
)
|
|
return JsonResponse({'ok': True, 'created': created})
|
|
|
|
|
|
@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})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
hidden_ids = set(
|
|
EpisodeProgress.objects.filter(
|
|
user=request.user,
|
|
).filter(
|
|
Q(played=True) | Q(dismissed=True)
|
|
).values_list('episode_id', flat=True)
|
|
)
|
|
|
|
limit = min(int(request.GET.get('limit', 200)), 1000)
|
|
offset = max(int(request.GET.get('offset', 0)), 0)
|
|
|
|
episodes = list(
|
|
PodcastEpisode.objects.filter(feed__user=request.user)
|
|
.exclude(id__in=hidden_ids)
|
|
.select_related('feed')
|
|
.order_by('-pub_date')
|
|
.values(
|
|
'id', 'title', '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=request.user,
|
|
episode_id__in=[e['id'] for e in episodes],
|
|
).values('episode_id', 'position_seconds')
|
|
}
|
|
queued_ids = set(
|
|
PodcastQueue.objects.filter(
|
|
user=request.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 JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit})
|