import time from functools import wraps import django.middleware.csrf as csrf from django.contrib.auth import authenticate, login from django.core.cache import caches from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Q, Sum, Value from django.db.models.functions import Coalesce from django.http import JsonResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.views.decorators.cache import cache_page from django.views.decorators.http import require_http_methods from django.conf import settings from mutagen import File from prometheus_client import Counter from marietje.utils import song_to_dict, playlist_song_to_dict, send_to_bertha from queues.models import PlaylistSong, QueueCommand from songs.models import Song request_counter = Counter('marietje_requests', 'Queue requests on marietje', ['queue']) upload_counter = Counter('marietje_uploads', 'Songs uploaded to marietje') def api_auth_required(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): if request.user.is_authenticated and request.user.is_active: return view_func(request, *args, **kwargs) response = JsonResponse({ 'error': 'User not authenticated or activated.' }) response.status_code = 401 return response return _wrapped_view def login_user(request): data = {'error': 'Method not allowed'} status = 405 if request.method == "POST": username = request.POST.get('username', '').strip() password = request.POST.get('password', '').strip() data = {'error': 'Please enter a correct username and password. ' 'Note that both fields may be case-sensitive.'} status = 401 if username and password: user = authenticate(username=username, password=password) if user is not None: if user.is_active: login(request, user) data = {} status = 200 else: data = {'error': 'User is not active'} status = 401 else: csrf.get_token(request) response = JsonResponse(data) response.status_code = status return response @api_auth_required def permissions(request): return JsonResponse({ 'can_move': request.user.has_perm('queues.can_move'), 'can_skip': request.user.has_perm('queues.can_skip'), 'can_cancel': request.user.has_perm('queues.can_cancel'), 'can_control_volume': request.user.has_perm('queues.can_control_volume') }) @api_auth_required def songs(request): try: pagesize = int(request.POST.get('pagesize')) except: pagesize = 50 if not pagesize or pagesize < 50: pagesize = 50 try: page = int(request.POST.get('page')) except: page = 1 def search_songs(): queries = [Q(deleted=False)] queries.extend([Q(Q(artist__icontains=word) | Q(title__icontains=word)) for word in request.POST.get('all', '').split()]) queries.extend([Q(user__name__icontains=word) for word in request.POST.get('uploader', '').split()]) filter_query = queries.pop() for query in queries: filter_query &= query songs_query = Song.objects.filter(filter_query).order_by('artist', 'title').select_related('user') paginator = Paginator(songs_query, pagesize) try: songs = paginator.page(page) except PageNotAnInteger: songs = paginator.page(1) except EmptyPage: songs = paginator.page(paginator.num_pages) songs_dict = [song_to_dict(song, include_user=True) for song in songs.object_list] return JsonResponse({ 'per_page': pagesize, 'current_page': page, 'last_page': paginator.num_pages, 'data': songs_dict, }) cache_key = '|'.join(request.POST.get(k, '') for k in ('all', 'uploader', 'pagesize', 'page')) return caches['song_search'].get_or_set(cache_key, search_songs, 60*60*2) @api_auth_required def managesongs(request): try: pagesize = int(request.POST.get('pagesize')) except: pagesize = 10 if not pagesize or pagesize < 10: pagesize = 10 try: page = int(request.POST.get('page')) except: page = 1 songs_query = Song.objects.filter( user=request.user, deleted=False, artist__icontains=request.POST.get('artist', ''), title__icontains=request.POST.get('title', '') ).order_by('artist', 'title') total = songs_query.count() paginator = Paginator(songs_query, pagesize) try: songs = paginator.page(page) except PageNotAnInteger: songs = paginator.page(1) except EmptyPage: songs = paginator.page(paginator.num_pages) songs_dict = [song_to_dict(song) for song in songs.object_list] return JsonResponse({ 'total': total, 'per_page': pagesize, 'current_page': page, 'last_page': paginator.num_pages, 'data': songs_dict }) @api_auth_required def queue(request): queue = request.user.queue infobar = {"start_personal_queue": 0, "length_personal_queue": 0, "length_total_queue": 0, "end_personal_queue": 0} for song in queue.queue(): infobar["length_total_queue"] += song.song.duration if song.user == request.user: infobar["length_personal_queue"] += song.song.duration infobar["end_personal_queue"] = infobar["length_total_queue"] if infobar["start_personal_queue"] == 0: infobar["start_personal_queue"] = infobar["length_total_queue"] - song.song.duration json = { 'current_song': playlist_song_to_dict(queue.current_song()), 'queue': [playlist_song_to_dict(playlist_song, user=request.user) for playlist_song in queue.queue()], 'started_at': 0 if queue.started_at is None else int(queue.started_at.timestamp()), 'current_time': int(time.time()), 'user_name': request.user.name, 'infobar': infobar, } return JsonResponse(json) @api_auth_required def skip(request): playlist_song = request.user.queue.current_song() if playlist_song.user != request.user and not request.user.has_perm('queues.can_skip'): return HttpResponseForbidden() playlist_song.state = 2 playlist_song.save() return JsonResponse({}) @require_http_methods(["POST"]) @api_auth_required def move_down(request): playlist_song = get_object_or_404(PlaylistSong, id=request.POST.get('id')) if playlist_song.user != request.user and not request.user.has_perm('queues.can_move'): return HttpResponseForbidden() playlist_song.move_down() return JsonResponse({}) @require_http_methods(["POST"]) @api_auth_required def cancel(request): playlist_song = get_object_or_404(PlaylistSong, id=request.POST.get('id')) if playlist_song.user != request.user and not request.user.has_perm('queues.can_cancel'): return HttpResponseForbidden() playlist_song.delete() return JsonResponse({}) @require_http_methods(["POST"]) @api_auth_required def request(request): queue = request.user.queue song = get_object_or_404(Song, id=request.POST.get('id'), deleted=False) err = queue.request(song, request.user) if err != None: return JsonResponse({'success': False, 'message': err}) request_counter.labels(queue=queue.name).inc() return JsonResponse({'success': True}) @require_http_methods(["POST"]) @api_auth_required def report(request): queue = request.user.queue song = get_object_or_404(Song, id=request.POST.get('id'), deleted=False) msg = request.POST.get('msg') err = song.report(request.user, msg) return JsonResponse({'success': True}) @require_http_methods(["POST"]) @api_auth_required def upload(request): files = request.FILES.getlist('file[]') artists = request.POST.getlist('artist[]') titles = request.POST.getlist('title[]') for artist in artists: if not artist: return JsonResponse({'success': False, 'errorMessage': 'Please enter artists which are not empty.'}) for title in titles: if not title: return JsonResponse({'success': False, 'errorMessage': 'Please enter titles which are not empty.'}) # Allow upload if the user has a good reputation # Score function: # - U = duration * songs uploaded # - Q = duration * songs queued # - If 2*U < Q: allow upload (otherwise don't) try: stats = upload_stats(request.user) ratio = stats['queued_score'] / (2.0 * stats['upload_score']) ratiostr = '{:.2f}'.format(ratio) except ZeroDivisionError: ratio = 99999.0 # high enough ratiostr = "∞" if not request.user.is_superuser and ratio < 1.0: msg = 'Queue-to-upload ratio too low. Please queue more during regular opening hours to improve the ratio. (Ratio: {} ≱ 1.00)' return JsonResponse({'success': False, 'errorMessage': msg.format(ratiostr)}) for i, file in enumerate(files): duration = File(file).info.length hash = send_to_bertha(file).decode('ascii') if not hash: return JsonResponse({'success': False, 'errorMessage': 'Files not uploaded correctly.'}) song = Song(user=request.user, artist=artists[i], title=titles[i], hash=hash, duration=duration) song.save() # Clear the search cache caches['song_search'].clear() upload_counter.inc() return JsonResponse({'success': True}) @require_http_methods(["POST"]) @api_auth_required def volume_down(request): if not request.user.has_perm('queues.can_control_volume'): return HttpResponseForbidden() command = QueueCommand(queue=request.user.queue, command='volume_down') command.save() return JsonResponse({}) @require_http_methods(["POST"]) @api_auth_required def volume_up(request): if not request.user.has_perm('queues.can_control_volume'): return HttpResponseForbidden() command = QueueCommand(queue=request.user.queue, command='volume_up') command.save() return JsonResponse({}) @require_http_methods(["POST"]) @api_auth_required def mute(request): if not request.user.has_perm('queues.can_control_volume'): return HttpResponseForbidden() command = QueueCommand(queue=request.user.queue, command='mute') command.save() return JsonResponse({}) def upload_stats(user): songs_queued = PlaylistSong.objects.select_related('song').filter( user=user, state=2, song__deleted=False) queued_score = sum(_request_weight(x) for x in songs_queued) upload_score = Song.objects.filter( user=user, deleted=False).aggregate( x=Coalesce(Sum('duration'), Value(0)))['x'] return {'queued_score': queued_score, 'upload_score': upload_score} @transaction.atomic def _request_weight(ps): def _is_regular_queue(ps): if not ps.played_at: # Request is from the old times, assume good return True if not 0 <= ps.played_at.astimezone().weekday() <= 4: return False # Queued in the weekend if not 7 <= ps.played_at.astimezone().hour <= 22: # Because of timezone shit, I allow for an extra hour of leeway return False # Queued outside of regular opening hours return True if _is_regular_queue(ps): return float(ps.song.duration) # Count other requests for 10% return 0.10 * float(ps.song.duration)