529 lines
17 KiB
Python
529 lines
17 KiB
Python
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,
|
|
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
|
}
|
|
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': {}})
|
|
|
|
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})
|