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