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, 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 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] _, 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 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 .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author') ) for f in feeds: if f['last_refreshed_at']: f['last_refreshed_at'] = f['last_refreshed_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) # --------------------------------------------------------------------------- # 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, }, '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', ) ) 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}) # --------------------------------------------------------------------------- # Inbox # --------------------------------------------------------------------------- @csrf_exempt def inbox(request): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) played_ids = set( EpisodeProgress.objects.filter( user=request.user, played=True, ).values_list('episode_id', flat=True) ) episodes = list( PodcastEpisode.objects.filter(feed__user=request.user) .exclude(id__in=played_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', )[:100] ) for ep in episodes: if ep['pub_date']: ep['pub_date'] = ep['pub_date'].isoformat() return JsonResponse({'episodes': episodes})