views.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import json
  2. import logging
  3. from datetime import datetime
  4. import pytz
  5. from django.conf import settings
  6. from django.contrib.auth.mixins import LoginRequiredMixin
  7. from django.db.models.fields import timezone
  8. from django.http import FileResponse, HttpResponseRedirect, JsonResponse
  9. from django.urls import reverse, reverse_lazy
  10. from django.utils import timezone
  11. from django.views.decorators.csrf import csrf_exempt
  12. from django.views.generic import FormView
  13. from django.views.generic.edit import CreateView
  14. from django.views.generic.list import ListView
  15. from music.aggregators import (
  16. scrobble_counts,
  17. top_artists,
  18. top_tracks,
  19. week_of_scrobbles,
  20. )
  21. from rest_framework import status
  22. from rest_framework.decorators import (
  23. api_view,
  24. parser_classes,
  25. permission_classes,
  26. )
  27. from rest_framework.parsers import MultiPartParser
  28. from rest_framework.permissions import IsAuthenticated
  29. from rest_framework.response import Response
  30. from scrobbles.constants import (
  31. JELLYFIN_AUDIO_ITEM_TYPES,
  32. JELLYFIN_VIDEO_ITEM_TYPES,
  33. )
  34. from scrobbles.export import export_scrobbles
  35. from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
  36. from scrobbles.imdb import lookup_video_from_imdb
  37. from scrobbles.models import AudioScrobblerTSVImport, LastFmImport, Scrobble
  38. from scrobbles.scrobblers import (
  39. jellyfin_scrobble_track,
  40. jellyfin_scrobble_video,
  41. manual_scrobble_event,
  42. manual_scrobble_video,
  43. mopidy_scrobble_podcast,
  44. mopidy_scrobble_track,
  45. )
  46. from scrobbles.serializers import (
  47. AudioScrobblerTSVImportSerializer,
  48. ScrobbleSerializer,
  49. )
  50. from scrobbles.tasks import process_lastfm_import, process_tsv_import
  51. from scrobbles.thesportsdb import lookup_event_from_thesportsdb
  52. logger = logging.getLogger(__name__)
  53. class RecentScrobbleList(ListView):
  54. model = Scrobble
  55. def get_context_data(self, **kwargs):
  56. data = super().get_context_data(**kwargs)
  57. user = self.request.user
  58. now = timezone.now()
  59. if user.is_authenticated:
  60. if user.profile:
  61. timezone.activate(pytz.timezone(user.profile.timezone))
  62. now = timezone.localtime(timezone.now())
  63. data['now_playing_list'] = Scrobble.objects.filter(
  64. in_progress=True,
  65. is_paused=False,
  66. timestamp__lte=now,
  67. user=user,
  68. )
  69. completed_for_user = Scrobble.objects.filter(
  70. played_to_completion=True, user=user
  71. )
  72. data['video_scrobble_list'] = completed_for_user.filter(
  73. video__isnull=False
  74. ).order_by('-timestamp')[:15]
  75. data['podcast_scrobble_list'] = completed_for_user.filter(
  76. podcast_episode__isnull=False
  77. ).order_by('-timestamp')[:15]
  78. data['sport_scrobble_list'] = completed_for_user.filter(
  79. sport_event__isnull=False
  80. ).order_by('-timestamp')[:15]
  81. # data['top_daily_tracks'] = top_tracks()
  82. data['top_weekly_tracks'] = top_tracks(user, filter='week')
  83. data['top_monthly_tracks'] = top_tracks(user, filter='month')
  84. # data['top_daily_artists'] = top_artists()
  85. data['top_weekly_artists'] = top_artists(user, filter='week')
  86. data['top_monthly_artists'] = top_artists(user, filter='month')
  87. data["weekly_data"] = week_of_scrobbles(user=user)
  88. data['counts'] = scrobble_counts(user)
  89. data['imdb_form'] = ScrobbleForm
  90. data['export_form'] = ExportScrobbleForm
  91. return data
  92. def get_queryset(self):
  93. return Scrobble.objects.filter(
  94. track__isnull=False, in_progress=False
  95. ).order_by('-timestamp')[:15]
  96. class ManualScrobbleView(FormView):
  97. form_class = ScrobbleForm
  98. template_name = 'scrobbles/manual_form.html'
  99. def form_valid(self, form):
  100. item_id = form.cleaned_data.get('item_id')
  101. data_dict = None
  102. if 'tt' in item_id:
  103. data_dict = lookup_video_from_imdb(
  104. form.cleaned_data.get('item_id')
  105. )
  106. if data_dict:
  107. manual_scrobble_video(data_dict, self.request.user.id)
  108. if not data_dict:
  109. logger.debug(f"Looking for sport event with ID {item_id}")
  110. data_dict = lookup_event_from_thesportsdb(
  111. form.cleaned_data.get('item_id')
  112. )
  113. if data_dict:
  114. manual_scrobble_event(data_dict, self.request.user.id)
  115. return HttpResponseRedirect(reverse("vrobbler-home"))
  116. class JsonableResponseMixin:
  117. """
  118. Mixin to add JSON support to a form.
  119. Must be used with an object-based FormView (e.g. CreateView)
  120. """
  121. def form_invalid(self, form):
  122. response = super().form_invalid(form)
  123. if self.request.accepts('text/html'):
  124. return response
  125. else:
  126. return JsonResponse(form.errors, status=400)
  127. def form_valid(self, form):
  128. # We make sure to call the parent's form_valid() method because
  129. # it might do some processing (in the case of CreateView, it will
  130. # call form.save() for example).
  131. response = super().form_valid(form)
  132. if self.request.accepts('text/html'):
  133. return response
  134. else:
  135. data = {
  136. 'pk': self.object.pk,
  137. }
  138. return JsonResponse(data)
  139. class AudioScrobblerImportCreateView(
  140. LoginRequiredMixin, JsonableResponseMixin, CreateView
  141. ):
  142. model = AudioScrobblerTSVImport
  143. fields = ['tsv_file']
  144. template_name = 'scrobbles/upload_form.html'
  145. success_url = reverse_lazy('vrobbler-home')
  146. def form_valid(self, form):
  147. self.object = form.save(commit=False)
  148. self.object.user = self.request.user
  149. self.object.save()
  150. process_tsv_import.delay(self.object.id)
  151. return HttpResponseRedirect(self.get_success_url())
  152. @permission_classes([IsAuthenticated])
  153. @api_view(['GET'])
  154. def lastfm_import(request):
  155. lfm_import, created = LastFmImport.objects.get_or_create(
  156. user=request.user, processed_finished__isnull=True
  157. )
  158. process_lastfm_import.delay(lfm_import.id)
  159. success_url = reverse_lazy('vrobbler-home')
  160. return HttpResponseRedirect(success_url)
  161. @csrf_exempt
  162. @api_view(['GET'])
  163. def scrobble_endpoint(request):
  164. """List all Scrobbles, or create a new Scrobble"""
  165. scrobble = Scrobble.objects.all()
  166. serializer = ScrobbleSerializer(scrobble, many=True)
  167. return Response(serializer.data)
  168. @csrf_exempt
  169. @permission_classes([IsAuthenticated])
  170. @api_view(['POST'])
  171. def jellyfin_websocket(request):
  172. data_dict = request.data
  173. if (
  174. data_dict['NotificationType'] == 'PlaybackProgress'
  175. and data_dict['ItemType'] == 'Audio'
  176. ):
  177. return Response({}, status=status.HTTP_304_NOT_MODIFIED)
  178. # For making things easier to build new input processors
  179. if getattr(settings, "DUMP_REQUEST_DATA", False):
  180. json_data = json.dumps(data_dict, indent=4)
  181. logger.debug(f"{json_data}")
  182. scrobble = None
  183. media_type = data_dict.get("ItemType", "")
  184. if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
  185. scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
  186. if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
  187. scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
  188. if not scrobble:
  189. return Response({}, status=status.HTTP_400_BAD_REQUEST)
  190. return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
  191. @csrf_exempt
  192. @permission_classes([IsAuthenticated])
  193. @api_view(['POST'])
  194. def mopidy_websocket(request):
  195. try:
  196. data_dict = json.loads(request.data)
  197. except TypeError:
  198. logger.warning('Received Mopidy data as dict, rather than a string')
  199. data_dict = request.data
  200. # For making things easier to build new input processors
  201. if getattr(settings, "DUMP_REQUEST_DATA", False):
  202. json_data = json.dumps(data_dict, indent=4)
  203. logger.debug(f"{json_data}")
  204. if 'podcast' in data_dict.get('mopidy_uri'):
  205. scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
  206. else:
  207. scrobble = mopidy_scrobble_track(data_dict, request.user.id)
  208. if not scrobble:
  209. return Response({}, status=status.HTTP_400_BAD_REQUEST)
  210. return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
  211. @csrf_exempt
  212. @permission_classes([IsAuthenticated])
  213. @api_view(['POST'])
  214. @parser_classes([MultiPartParser])
  215. def import_audioscrobbler_file(request):
  216. """Takes a TSV file in the Audioscrobbler format, saves it and processes the
  217. scrobbles.
  218. """
  219. scrobbles_created = []
  220. # tsv_file = request.FILES[0]
  221. file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
  222. if file_serializer.is_valid():
  223. import_file = file_serializer.save()
  224. return Response(
  225. {'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK
  226. )
  227. else:
  228. return Response(
  229. file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
  230. )
  231. @csrf_exempt
  232. @permission_classes([IsAuthenticated])
  233. @api_view(['GET'])
  234. def scrobble_finish(request, uuid):
  235. user = request.user
  236. if not user.is_authenticated:
  237. return Response({}, status=status.HTTP_403_FORBIDDEN)
  238. scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
  239. if not scrobble:
  240. return Response({}, status=status.HTTP_404_NOT_FOUND)
  241. scrobble.stop(force_finish=True)
  242. return Response(
  243. {'id': scrobble.id, 'status': scrobble.status},
  244. status=status.HTTP_200_OK,
  245. )
  246. @csrf_exempt
  247. @permission_classes([IsAuthenticated])
  248. @api_view(['GET'])
  249. def scrobble_cancel(request, uuid):
  250. user = request.user
  251. if not user.is_authenticated:
  252. return Response({}, status=status.HTTP_403_FORBIDDEN)
  253. scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
  254. if not scrobble:
  255. return Response({}, status=status.HTTP_404_NOT_FOUND)
  256. scrobble.cancel()
  257. return Response(
  258. {'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
  259. )
  260. @permission_classes([IsAuthenticated])
  261. @api_view(['GET'])
  262. def export(request):
  263. format = request.GET.get('export_type', 'csv')
  264. start = request.GET.get('start')
  265. end = request.GET.get('end')
  266. logger.debug(f"Exporting all scrobbles in format {format}")
  267. temp_file, extension = export_scrobbles(
  268. start_date=start, end_date=end, format=format
  269. )
  270. now = datetime.now()
  271. filename = f"vrobbler-export-{str(now)}.{extension}"
  272. response = FileResponse(open(temp_file, 'rb'))
  273. response["Content-Disposition"] = f'attachment; filename="{filename}"'
  274. return response