import calendar import json import logging from datetime import datetime, timedelta from django.db.models.query import QuerySet import pytz from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q from django.db.models.fields import timezone from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse, reverse_lazy from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.generic import DetailView, FormView, TemplateView from django.views.generic.edit import CreateView from django.views.generic.list import ListView from music.aggregators import ( scrobble_counts, top_artists, top_tracks, week_of_scrobbles, ) from rest_framework import status from rest_framework.decorators import ( api_view, parser_classes, permission_classes, ) from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from scrobbles.api import serializers from scrobbles.constants import ( JELLYFIN_AUDIO_ITEM_TYPES, JELLYFIN_VIDEO_ITEM_TYPES, ) from scrobbles.export import export_scrobbles from scrobbles.forms import ExportScrobbleForm, ScrobbleForm from scrobbles.imdb import lookup_video_from_imdb from scrobbles.models import ( AudioScrobblerTSVImport, ChartRecord, KoReaderImport, LastFmImport, Scrobble, ) from scrobbles.scrobblers import ( jellyfin_scrobble_track, jellyfin_scrobble_video, manual_scrobble_event, manual_scrobble_video, mopidy_scrobble_podcast, mopidy_scrobble_track, ) from scrobbles.tasks import ( process_koreader_import, process_lastfm_import, process_tsv_import, ) from scrobbles.thesportsdb import lookup_event_from_thesportsdb logger = logging.getLogger(__name__) class RecentScrobbleList(ListView): model = Scrobble def get_context_data(self, **kwargs): data = super().get_context_data(**kwargs) user = self.request.user if user.is_authenticated: completed_for_user = Scrobble.objects.filter( played_to_completion=True, user=user ) data['video_scrobble_list'] = completed_for_user.filter( video__isnull=False ).order_by('-timestamp')[:15] data['podcast_scrobble_list'] = completed_for_user.filter( podcast_episode__isnull=False ).order_by('-timestamp')[:15] data['sport_scrobble_list'] = completed_for_user.filter( sport_event__isnull=False ).order_by('-timestamp')[:15] data["weekly_data"] = week_of_scrobbles(user=user) data['counts'] = scrobble_counts(user) data['imdb_form'] = ScrobbleForm data['export_form'] = ExportScrobbleForm data['active_imports'] = AudioScrobblerTSVImport.objects.filter( processing_started__isnull=False, processed_finished__isnull=True, user=self.request.user, ) return data def get_queryset(self): return Scrobble.objects.filter( track__isnull=False, in_progress=False ).order_by('-timestamp')[:15] class ScrobbleImportListView(TemplateView): template_name = "scrobbles/import_list.html" def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) context_data['object_list'] = [] context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter( user=self.request.user, ).order_by('-processing_started') context_data["koreader_imports"] = KoReaderImport.objects.filter( user=self.request.user, ).order_by('-processing_started') context_data["lastfm_imports"] = LastFmImport.objects.filter( user=self.request.user, ).order_by('-processing_started') return context_data class BaseScrobbleImportDetailView(DetailView): slug_field = 'uuid' template_name = "scrobbles/import_detail.html" def get_queryset(self): return super().get_queryset().filter(user=self.request.user) def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) title = "Generic Scrobble Import" if self.model == KoReaderImport: title = "KoReader Import" if self.model == AudioScrobblerTSVImport: title = "Audioscrobbler TSV Import" if self.model == LastFmImport: title = "LastFM Import" context_data['title'] = title return context_data class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView): model = KoReaderImport class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView): model = AudioScrobblerTSVImport class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView): model = LastFmImport class ManualScrobbleView(FormView): form_class = ScrobbleForm template_name = 'scrobbles/manual_form.html' def form_valid(self, form): item_id = form.cleaned_data.get('item_id') data_dict = None if 'tt' in item_id: data_dict = lookup_video_from_imdb( form.cleaned_data.get('item_id') ) if data_dict: manual_scrobble_video(data_dict, self.request.user.id) if not data_dict: logger.debug(f"Looking for sport event with ID {item_id}") data_dict = lookup_event_from_thesportsdb( form.cleaned_data.get('item_id') ) if data_dict: manual_scrobble_event(data_dict, self.request.user.id) return HttpResponseRedirect(reverse("vrobbler-home")) class JsonableResponseMixin: """ Mixin to add JSON support to a form. Must be used with an object-based FormView (e.g. CreateView) """ def form_invalid(self, form): response = super().form_invalid(form) if self.request.accepts('text/html'): return response else: return JsonResponse(form.errors, status=400) def form_valid(self, form): # We make sure to call the parent's form_valid() method because # it might do some processing (in the case of CreateView, it will # call form.save() for example). response = super().form_valid(form) if self.request.accepts('text/html'): return response else: data = { 'pk': self.object.pk, } return JsonResponse(data) class AudioScrobblerImportCreateView( LoginRequiredMixin, JsonableResponseMixin, CreateView ): model = AudioScrobblerTSVImport fields = ['tsv_file'] template_name = 'scrobbles/upload_form.html' success_url = reverse_lazy('vrobbler-home') def form_valid(self, form): self.object = form.save(commit=False) self.object.user = self.request.user self.object.save() process_tsv_import.delay(self.object.id) return HttpResponseRedirect(self.get_success_url()) class KoReaderImportCreateView( LoginRequiredMixin, JsonableResponseMixin, CreateView ): model = KoReaderImport fields = ['sqlite_file'] template_name = 'scrobbles/upload_form.html' success_url = reverse_lazy('vrobbler-home') def form_valid(self, form): self.object = form.save(commit=False) self.object.user = self.request.user self.object.save() process_koreader_import.delay(self.object.id) return HttpResponseRedirect(self.get_success_url()) @permission_classes([IsAuthenticated]) @api_view(['GET']) def lastfm_import(request): lfm_import, created = LastFmImport.objects.get_or_create( user=request.user, processed_finished__isnull=True ) process_lastfm_import.delay(lfm_import.id) success_url = reverse_lazy('vrobbler-home') return HttpResponseRedirect(success_url) @csrf_exempt @permission_classes([IsAuthenticated]) @api_view(['POST']) def jellyfin_webhook(request): data_dict = request.data if ( data_dict['NotificationType'] == 'PlaybackProgress' and data_dict['ItemType'] == 'Audio' ): return Response({}, status=status.HTTP_304_NOT_MODIFIED) # For making things easier to build new input processors if getattr(settings, "DUMP_REQUEST_DATA", False): json_data = json.dumps(data_dict, indent=4) logger.debug(f"{json_data}") scrobble = None media_type = data_dict.get("ItemType", "") if media_type in JELLYFIN_AUDIO_ITEM_TYPES: scrobble = jellyfin_scrobble_track(data_dict, request.user.id) if media_type in JELLYFIN_VIDEO_ITEM_TYPES: scrobble = jellyfin_scrobble_video(data_dict, request.user.id) if not scrobble: return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK) @csrf_exempt @permission_classes([IsAuthenticated]) @api_view(['POST']) def mopidy_webhook(request): try: data_dict = json.loads(request.data) except TypeError: logger.warning('Received Mopidy data as dict, rather than a string') data_dict = request.data # For making things easier to build new input processors if getattr(settings, "DUMP_REQUEST_DATA", False): json_data = json.dumps(data_dict, indent=4) logger.debug(f"{json_data}") if 'podcast' in data_dict.get('mopidy_uri'): scrobble = mopidy_scrobble_podcast(data_dict, request.user.id) else: scrobble = mopidy_scrobble_track(data_dict, request.user.id) if not scrobble: return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK) @csrf_exempt @permission_classes([IsAuthenticated]) @api_view(['POST']) @parser_classes([MultiPartParser]) def import_audioscrobbler_file(request): """Takes a TSV file in the Audioscrobbler format, saves it and processes the scrobbles. """ scrobbles_created = [] # tsv_file = request.FILES[0] file_serializer = serializers.AudioScrobblerTSVImportSerializer( data=request.data ) if file_serializer.is_valid(): import_file = file_serializer.save() return Response( {'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK ) else: return Response( file_serializer.errors, status=status.HTTP_400_BAD_REQUEST ) @permission_classes([IsAuthenticated]) @api_view(['GET']) def scrobble_finish(request, uuid): user = request.user success_url = reverse_lazy('vrobbler-home') if not user.is_authenticated: return HttpResponseRedirect(success_url) scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first() if scrobble: scrobble.stop(force_finish=True) messages.add_message( request, messages.SUCCESS, f"Scrobble of {scrobble.media_obj} finished.", ) else: messages.add_message(request, messages.ERROR, "Scrobble not found.") return HttpResponseRedirect(success_url) @permission_classes([IsAuthenticated]) @api_view(['GET']) def scrobble_cancel(request, uuid): user = request.user success_url = reverse_lazy('vrobbler-home') if not user.is_authenticated: return HttpResponseRedirect(success_url) scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first() if scrobble: scrobble.cancel() messages.add_message( request, messages.SUCCESS, f"Scrobble of {scrobble.media_obj} cancelled.", ) else: messages.add_message(request, messages.ERROR, "Scrobble not found.") return HttpResponseRedirect(success_url) @permission_classes([IsAuthenticated]) @api_view(['GET']) def export(request): format = request.GET.get('export_type', 'csv') start = request.GET.get('start') end = request.GET.get('end') logger.debug(f"Exporting all scrobbles in format {format}") temp_file, extension = export_scrobbles( start_date=start, end_date=end, format=format ) now = datetime.now() filename = f"vrobbler-export-{str(now)}.{extension}" response = FileResponse(open(temp_file, 'rb')) response["Content-Disposition"] = f'attachment; filename="{filename}"' return response class ChartRecordView(TemplateView): template_name = 'scrobbles/chart_index.html' @staticmethod def get_media_filter(media_type: str = "Track"): media_filter = Q() if media_type == 'Track': media_filter = Q(track__isnull=False) if media_type == 'Artist': media_filter = Q(artist__isnull=False) if media_type == 'Series': media_filter = Q(series__isnull=False) if media_type == 'Video': media_filter = Q(video__isnull=False) return media_filter def get_chart_records(self, media_type: str = "Track", **kwargs): media_filter = self.get_media_filter(media_type) charts = ChartRecord.objects.filter( media_filter, user=self.request.user, **kwargs ).order_by("rank") if charts.count() == 0: ChartRecord.build( user=self.request.user, model_str=media_type, **kwargs ) charts = ChartRecord.objects.filter( media_filter, user=self.request.user, **kwargs ).order_by("rank") return charts def get_chart( self, period: str = "all_time", limit=15, media: str = "Track" ) -> QuerySet: chart = QuerySet() now = timezone.now() if period == "all_time": chart = self.get_chart_records(media_type=media) if period == "today": chart = self.get_chart_records( media_type=media, day=now.day, month=now.month, year=now.year, ) if period == "week": chart = self.get_chart_records( media_type=media, year=now.year, week=now.isocalendar()[1], ) if period == "month": chart = self.get_chart_records( media_type=media, year=now.year, month=now.month, ) if period == "year": chart = self.get_chart_records( media_type=media, year=now.year, ) return chart[:limit] def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) date = self.request.GET.get('date') media_type = self.request.GET.get('media') user = self.request.user params = {} context_data['artist_charts'] = {} if not date: context_data['artist_charts'] = { "today": top_artists(user, filter="today")[:30], "week": top_artists(user, filter="week")[:30], "month": top_artists(user, filter="month")[:30], "all": top_artists(user), } context_data['track_charts'] = { "today": top_tracks(user, filter="today")[:30], "week": top_tracks(user, filter="week")[:30], "month": top_tracks(user, filter="month")[:30], "all": top_tracks(user), } return context_data now = timezone.now() year = now.year params = {'year': year} name = f"Chart for {year}" date_params = date.split('-') year = int(date_params[0]) in_progress = False if len(date_params) == 2: if 'W' in date_params[1]: week = int(date_params[1].strip('W"')) params['week'] = week start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace( tzinfo=pytz.utc ) end = start + timedelta(days=6) in_progress = start <= now <= end as_str = start.strftime('Week of %B %d, %Y') name = f"Chart for {as_str}" else: month = int(date_params[1]) params['month'] = month month_str = calendar.month_name[month] name = f"Chart for {month_str} {year}" in_progress = now.month == month and now.year == year if len(date_params) == 3: month = int(date_params[1]) day = int(date_params[2]) params['month'] = month params['day'] = day month_str = calendar.month_name[month] name = f"Chart for {month_str} {day}, {year}" in_progress = ( now.month == month and now.year == year and now.day == day ) media_filter = self.get_media_filter(media_type) charts = ChartRecord.objects.filter( media_filter, user=self.request.user, **params ).order_by("rank") if charts.count() == 0: ChartRecord.build( user=self.request.user, model_str=media_type, **params ) charts = ChartRecord.objects.filter( media_filter, user=self.request.user, **params ).order_by("rank") if in_progress: # TODO recalculate ... context_data['name'] = name context_data['in_progress'] = in_progress return context_data