diora-web/radio/views.py

547 lines
18 KiB
Python
Raw Normal View History

2026-03-16 19:19:22 +01:00
import json
import time
import urllib.parse
from datetime import datetime
import requests
from django.db.models import Count
2026-03-16 19:19:22 +01:00
from django.conf import settings
from django.http import (
HttpResponse,
HttpResponseBadRequest,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .icy import stream_icy_metadata
from . import lastfm as lastfm_module
from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation
2026-03-16 19:19:22 +01:00
# ---------------------------------------------------------------------------
# Index / player
# ---------------------------------------------------------------------------
def index(request):
saved_stations = []
history = []
if request.user.is_authenticated:
play_counts = {
sp['station_url']: sp['count']
for sp in StationPlay.objects
.filter(user=request.user)
.values('station_url')
.annotate(count=Count('id'))
}
2026-03-16 19:19:22 +01:00
saved_stations = list(
request.user.saved_stations.values(
'id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite'
)
)
for s in saved_stations:
s['play_count'] = play_counts.get(s['url'], 0)
2026-03-16 19:19:22 +01:00
history = list(
request.user.track_history.values(
'id', 'station_name', 'track', 'played_at', 'scrobbled'
2026-03-16 19:19:22 +01:00
)[:50]
)
# Convert datetime to ISO string for JSON serialisation in template
for entry in history:
entry['played_at'] = entry['played_at'].isoformat()
featured = list(
FeaturedStation.objects.filter(active=True).values(
'id', 'name', 'url', 'description', 'favicon_url', 'tags'
)
)
2026-03-16 19:19:22 +01:00
context = {
'saved_stations': saved_stations,
'history': history,
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'featured_stations': featured,
2026-03-16 19:19:22 +01:00
}
return render(request, 'radio/player.html', context)
# ---------------------------------------------------------------------------
# SSE metadata stream
# ---------------------------------------------------------------------------
@csrf_exempt
def sse_metadata(request):
url = request.GET.get('url', '').strip()
if not url:
return HttpResponseBadRequest('url parameter required')
def event_stream():
last_title = None
try:
for title in stream_icy_metadata(url):
if title != last_title:
last_title = title
yield f"data: {json.dumps({'track': title})}\n\n"
except Exception:
yield f"data: {json.dumps({'error': 'stream ended'})}\n\n"
response = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response
# ---------------------------------------------------------------------------
# Record track
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def record_track(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
station_name = body.get('station_name', '').strip()
track = body.get('track', '').strip()
do_scrobble = body.get('scrobble', False)
if not station_name or not track:
return JsonResponse({'error': 'station_name and track required'}, status=400)
# Ensure session exists for anonymous users
if not request.session.session_key:
request.session.create()
history_entry = TrackHistory(
station_name=station_name,
track=track,
)
if request.user.is_authenticated:
history_entry.user = request.user
else:
history_entry.session_key = request.session.session_key
# Attempt scrobble before saving (so we can mark it)
scrobbled = False
if (
do_scrobble
and request.user.is_authenticated
and hasattr(request.user, 'profile')
and request.user.profile.has_lastfm()
and request.user.profile.lastfm_scrobble
):
try:
artist, title = lastfm_module.parse_track(track)
if not artist:
artist = station_name
lastfm_module.scrobble(
session_key=request.user.profile.lastfm_session_key,
artist=artist,
title=title,
timestamp=int(time.time()),
)
scrobbled = True
except Exception:
pass # Scrobble failure is non-fatal
history_entry.scrobbled = scrobbled
history_entry.save()
return JsonResponse({'ok': True, 'scrobbled': scrobbled})
# ---------------------------------------------------------------------------
# Affiliate links
# ---------------------------------------------------------------------------
def affiliate_links(request):
if not settings.AMAZON_AFFILIATE_ENABLED:
return JsonResponse({'amazon_url': None, 'itunes_data': {}})
2026-03-16 19:19:22 +01:00
track = request.GET.get('track', '').strip()
if not track:
return JsonResponse({'error': 'track parameter required'}, status=400)
itunes_data = {}
try:
itunes_url = (
f"https://itunes.apple.com/search"
f"?term={urllib.parse.quote(track)}&media=music&limit=1"
)
resp = requests.get(itunes_url, timeout=5)
resp.raise_for_status()
results = resp.json().get('results', [])
if results:
r = results[0]
itunes_data = {
'name': r.get('trackName', ''),
'artist': r.get('artistName', ''),
'album': r.get('collectionName', ''),
'artwork': r.get('artworkUrl100', ''),
}
except Exception:
pass
amazon_url = (
f"https://www.amazon.com/s"
f"?k={urllib.parse.quote(track)}"
f"&i=digital-music"
f"&tag={settings.AMAZON_AFFILIATE_TAG}"
)
return JsonResponse({'amazon_url': amazon_url, 'itunes_data': itunes_data})
# ---------------------------------------------------------------------------
# Save station
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def save_station(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)
name = body.get('name', '').strip()
url = body.get('url', '').strip()
if not name or not url:
return JsonResponse({'error': 'name and url required'}, status=400)
station, created = SavedStation.objects.get_or_create(
user=request.user,
url=url,
defaults={
'name': name,
'bitrate': body.get('bitrate', ''),
'country': body.get('country', ''),
'tags': body.get('tags', ''),
'favicon_url': body.get('favicon_url', ''),
},
)
if not created:
# Update mutable fields in case station details changed
station.name = name
station.bitrate = body.get('bitrate', station.bitrate)
station.country = body.get('country', station.country)
station.tags = body.get('tags', station.tags)
station.favicon_url = body.get('favicon_url', station.favicon_url)
station.save()
return JsonResponse({'ok': True, 'id': station.id, 'created': created})
# ---------------------------------------------------------------------------
# Remove station
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def remove_station(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
station = SavedStation.objects.get(pk=pk, user=request.user)
station.delete()
return JsonResponse({'ok': True})
except SavedStation.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# ---------------------------------------------------------------------------
# Toggle favorite
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def toggle_favorite(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
station = SavedStation.objects.get(pk=pk, user=request.user)
station.is_favorite = not station.is_favorite
station.save(update_fields=['is_favorite'])
return JsonResponse({'ok': True, 'is_favorite': station.is_favorite})
except SavedStation.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# ---------------------------------------------------------------------------
# Station play tracking
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def start_play(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)
station_name = body.get('station_name', '').strip()
station_url = body.get('station_url', '').strip()
if not station_name or not station_url:
return JsonResponse({'error': 'station_name and station_url required'}, status=400)
play = StationPlay(
user=request.user,
station_name=station_name,
station_url=station_url,
)
play.save()
return JsonResponse({'ok': True, 'play_id': play.id})
@csrf_exempt
@require_http_methods(['POST'])
def stop_play(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
play_id = body.get('play_id')
if play_id is None:
return JsonResponse({'error': 'play_id required'}, status=400)
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
play = StationPlay.objects.get(id=play_id, user=request.user)
except StationPlay.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
play.ended_at = timezone.now()
play.save(update_fields=['ended_at'])
return JsonResponse({'ok': True})
# ---------------------------------------------------------------------------
# Recommendations
# ---------------------------------------------------------------------------
def _time_context_label(hour, is_weekend):
prefix = 'weekend' if is_weekend else 'weekday'
if 5 <= hour <= 11:
period = 'morning'
elif 12 <= hour <= 16:
period = 'afternoon'
elif 17 <= hour <= 21:
period = 'evening'
else:
period = 'night'
return f'{prefix} {period}'
def recommendations(request):
if not request.user.is_authenticated:
return JsonResponse({'recommendations': [], 'context': ''})
now = datetime.now()
current_hour = now.hour
current_is_weekend = now.weekday() >= 5
# Build ±2 hour window (wrapping around midnight)
hour_window = [(current_hour + i) % 24 for i in range(-2, 3)]
from django.db.models import Count
plays_qs = (
StationPlay.objects
.filter(
user=request.user,
hour_of_day__in=hour_window,
is_weekend=current_is_weekend,
)
.values('station_url', 'station_name')
.annotate(play_count=Count('id'))
.order_by('-play_count')[:5]
)
# Build a lookup of saved stations by URL
saved_by_url = {
s['url']: s
for s in request.user.saved_stations.values('id', 'name', 'url', 'favicon_url')
}
results = []
for entry in plays_qs:
saved = saved_by_url.get(entry['station_url'])
results.append({
'station_name': saved['name'] if saved else entry['station_name'],
'station_url': entry['station_url'],
'play_count': entry['play_count'],
'saved_id': saved['id'] if saved else None,
})
context_label = _time_context_label(current_hour, current_is_weekend)
return JsonResponse({'recommendations': results, 'context': context_label})
# ---------------------------------------------------------------------------
# History JSON
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def delete_history_entry(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
entry = TrackHistory.objects.get(pk=pk, user=request.user)
entry.delete()
return JsonResponse({'ok': True})
except TrackHistory.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
2026-03-16 19:19:22 +01:00
def history_json(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
entries = list(
request.user.track_history.values(
'station_name', 'track', 'played_at', 'scrobbled'
)[:200]
)
for entry in entries:
entry['played_at'] = entry['played_at'].isoformat()
return JsonResponse(entries, safe=False)
# ---------------------------------------------------------------------------
# Station notes
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def save_station_notes(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
station = SavedStation.objects.get(pk=pk, user=request.user)
except SavedStation.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
station.notes = body.get('notes', '').strip()
station.save()
return JsonResponse({'ok': True})
# ---------------------------------------------------------------------------
# Focus session
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def record_focus_session(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)
session = FocusSession.objects.create(
user=request.user,
station_name=body.get('station_name', ''),
duration_minutes=body.get('duration_minutes', 25),
)
return JsonResponse({'ok': True, 'id': session.id})
def focus_stats(request):
if not request.user.is_authenticated:
return JsonResponse({'today_sessions': 0, 'today_minutes': 0, 'sessions': []})
from django.utils.timezone import now, localtime
from django.db.models import Sum
today = localtime(now()).date()
qs = request.user.focus_sessions.all()
today_qs = qs.filter(completed_at__date=today)
today_count = today_qs.count()
today_minutes = today_qs.aggregate(t=Sum('duration_minutes'))['t'] or 0
recent = list(qs.values('station_name', 'completed_at', 'duration_minutes')[:50])
for s in recent:
s['completed_at'] = s['completed_at'].isoformat()
return JsonResponse({
'today_sessions': today_count,
'today_minutes': today_minutes,
'sessions': recent,
})
# ---------------------------------------------------------------------------
# M3U import
# ---------------------------------------------------------------------------
@require_http_methods(['POST'])
def import_m3u(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)
if not f.name.lower().endswith(('.m3u', '.m3u8')):
return JsonResponse({'error': 'file must be .m3u or .m3u8'}, status=400)
content = f.read().decode('utf-8', errors='replace')
lines = content.splitlines()
stations = []
pending_name = None
for line in lines:
line = line.strip()
if line.startswith('#EXTINF'):
comma = line.find(',')
pending_name = line[comma + 1:].strip() if comma != -1 else None
elif line and not line.startswith('#'):
parsed = urllib.parse.urlparse(line)
name = pending_name or parsed.netloc or line
stations.append({'name': name, 'url': line})
pending_name = None
if not stations:
return JsonResponse({'error': 'no stations found in file'}, status=400)
added = 0
skipped = 0
for s in stations:
_, created = SavedStation.objects.get_or_create(
user=request.user,
url=s['url'],
defaults={'name': s['name']},
)
if created:
added += 1
else:
skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})