diora-web/podcasts/views.py
marwin e9c5b8058b
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Centralize remaining magic numbers in settings.py
- HIGHLIGHTS_MAX_BYTES, BOOKMARKS_MAX_BYTES: books size limits now in settings
- PODCAST_INBOX_PAGE_SIZE: shared between podcasts/views.py and app.js via DIORA_CONFIG
- VOLUME_DEFAULT: radio stream player default volume
- ITUNES_TIMEOUT: unified iTunes API timeout (was 5s vs 6s inconsistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:10:14 +02:00

658 lines
22 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=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)
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', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 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', '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=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})