import json import time import urllib.parse from datetime import datetime import requests 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 # --------------------------------------------------------------------------- # Index / player # --------------------------------------------------------------------------- def index(request): saved_stations = [] history = [] if request.user.is_authenticated: saved_stations = list( request.user.saved_stations.values( 'id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite' ) ) history = list( request.user.track_history.values( 'id', 'station_name', 'track', 'played_at', 'scrobbled' )[:50] ) # Convert datetime to ISO string for JSON serialisation in template for entry in history: entry['played_at'] = entry['played_at'].isoformat() context = { 'saved_stations': saved_stations, 'history': history, } 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): 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) 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})