views.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import calendar
  2. import json
  3. import logging
  4. from datetime import datetime, timedelta
  5. import pytz
  6. from django.conf import settings
  7. from django.contrib import messages
  8. from django.contrib.auth.mixins import LoginRequiredMixin
  9. from django.db.models import Q
  10. from django.db.models.fields import timezone
  11. from django.db.models.query import QuerySet
  12. from django.http import FileResponse, HttpResponseRedirect, JsonResponse
  13. from django.urls import reverse, reverse_lazy
  14. from django.utils import timezone
  15. from django.views.decorators.csrf import csrf_exempt
  16. from django.views.generic import DetailView, FormView, TemplateView
  17. from django.views.generic.edit import CreateView
  18. from django.views.generic.list import ListView
  19. from music.aggregators import scrobble_counts, week_of_scrobbles
  20. from rest_framework import status
  21. from rest_framework.decorators import (
  22. api_view,
  23. parser_classes,
  24. permission_classes,
  25. )
  26. from rest_framework.parsers import MultiPartParser
  27. from rest_framework.permissions import IsAuthenticated
  28. from rest_framework.response import Response
  29. from scrobbles.api import serializers
  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 (
  38. AudioScrobblerTSVImport,
  39. ChartRecord,
  40. KoReaderImport,
  41. LastFmImport,
  42. Scrobble,
  43. )
  44. from scrobbles.scrobblers import (
  45. jellyfin_scrobble_track,
  46. jellyfin_scrobble_video,
  47. manual_scrobble_event,
  48. manual_scrobble_video,
  49. mopidy_scrobble_podcast,
  50. mopidy_scrobble_track,
  51. )
  52. from scrobbles.tasks import (
  53. process_koreader_import,
  54. process_lastfm_import,
  55. process_tsv_import,
  56. )
  57. from scrobbles.thesportsdb import lookup_event_from_thesportsdb
  58. from vrobbler.apps.music.aggregators import live_charts
  59. logger = logging.getLogger(__name__)
  60. class RecentScrobbleList(ListView):
  61. model = Scrobble
  62. def get_context_data(self, **kwargs):
  63. data = super().get_context_data(**kwargs)
  64. user = self.request.user
  65. if user.is_authenticated:
  66. completed_for_user = Scrobble.objects.filter(
  67. played_to_completion=True, user=user
  68. )
  69. data['video_scrobble_list'] = completed_for_user.filter(
  70. video__isnull=False
  71. ).order_by('-timestamp')[:15]
  72. data['podcast_scrobble_list'] = completed_for_user.filter(
  73. podcast_episode__isnull=False
  74. ).order_by('-timestamp')[:15]
  75. data['sport_scrobble_list'] = completed_for_user.filter(
  76. sport_event__isnull=False
  77. ).order_by('-timestamp')[:15]
  78. data['active_imports'] = AudioScrobblerTSVImport.objects.filter(
  79. processing_started__isnull=False,
  80. processed_finished__isnull=True,
  81. user=self.request.user,
  82. )
  83. l = 14
  84. artist = {'user': user, 'media_type': 'Artist'}
  85. data['current_artist_charts'] = {
  86. "today": live_charts(**artist, chart_period="today", limit=l),
  87. "week": live_charts(**artist, chart_period="week", limit=l),
  88. "month": live_charts(**artist, chart_period="month", limit=l),
  89. "year": live_charts(**artist, chart_period="year", limit=l),
  90. "all": live_charts(**artist, limit=l),
  91. }
  92. track = {'user': user, 'media_type': 'Track'}
  93. data['current_track_charts'] = {
  94. "today": live_charts(**track, chart_period="today", limit=l),
  95. "week": live_charts(**track, chart_period="week", limit=l),
  96. "month": live_charts(**track, chart_period="month", limit=l),
  97. "year": live_charts(**track, chart_period="year", limit=l),
  98. "all": live_charts(**track, limit=l),
  99. }
  100. data["weekly_data"] = week_of_scrobbles(user=user)
  101. data['counts'] = scrobble_counts(user)
  102. data['imdb_form'] = ScrobbleForm
  103. data['export_form'] = ExportScrobbleForm
  104. return data
  105. def get_queryset(self):
  106. return Scrobble.objects.filter(
  107. track__isnull=False, in_progress=False
  108. ).order_by('-timestamp')[:15]
  109. class ScrobbleImportListView(TemplateView):
  110. template_name = "scrobbles/import_list.html"
  111. def get_context_data(self, **kwargs):
  112. context_data = super().get_context_data(**kwargs)
  113. context_data['object_list'] = []
  114. context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter(
  115. user=self.request.user,
  116. ).order_by('-processing_started')
  117. context_data["koreader_imports"] = KoReaderImport.objects.filter(
  118. user=self.request.user,
  119. ).order_by('-processing_started')
  120. context_data["lastfm_imports"] = LastFmImport.objects.filter(
  121. user=self.request.user,
  122. ).order_by('-processing_started')
  123. return context_data
  124. class BaseScrobbleImportDetailView(DetailView):
  125. slug_field = 'uuid'
  126. template_name = "scrobbles/import_detail.html"
  127. def get_queryset(self):
  128. return super().get_queryset().filter(user=self.request.user)
  129. def get_context_data(self, **kwargs):
  130. context_data = super().get_context_data(**kwargs)
  131. title = "Generic Scrobble Import"
  132. if self.model == KoReaderImport:
  133. title = "KoReader Import"
  134. if self.model == AudioScrobblerTSVImport:
  135. title = "Audioscrobbler TSV Import"
  136. if self.model == LastFmImport:
  137. title = "LastFM Import"
  138. context_data['title'] = title
  139. return context_data
  140. class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView):
  141. model = KoReaderImport
  142. class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView):
  143. model = AudioScrobblerTSVImport
  144. class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView):
  145. model = LastFmImport
  146. class ManualScrobbleView(FormView):
  147. form_class = ScrobbleForm
  148. template_name = 'scrobbles/manual_form.html'
  149. def form_valid(self, form):
  150. item_id = form.cleaned_data.get('item_id')
  151. data_dict = None
  152. if 'tt' in item_id:
  153. data_dict = lookup_video_from_imdb(
  154. form.cleaned_data.get('item_id')
  155. )
  156. if data_dict:
  157. manual_scrobble_video(data_dict, self.request.user.id)
  158. if not data_dict:
  159. logger.debug(f"Looking for sport event with ID {item_id}")
  160. data_dict = lookup_event_from_thesportsdb(
  161. form.cleaned_data.get('item_id')
  162. )
  163. if data_dict:
  164. manual_scrobble_event(data_dict, self.request.user.id)
  165. return HttpResponseRedirect(reverse("vrobbler-home"))
  166. class JsonableResponseMixin:
  167. """
  168. Mixin to add JSON support to a form.
  169. Must be used with an object-based FormView (e.g. CreateView)
  170. """
  171. def form_invalid(self, form):
  172. response = super().form_invalid(form)
  173. if self.request.accepts('text/html'):
  174. return response
  175. else:
  176. return JsonResponse(form.errors, status=400)
  177. def form_valid(self, form):
  178. # We make sure to call the parent's form_valid() method because
  179. # it might do some processing (in the case of CreateView, it will
  180. # call form.save() for example).
  181. response = super().form_valid(form)
  182. if self.request.accepts('text/html'):
  183. return response
  184. else:
  185. data = {
  186. 'pk': self.object.pk,
  187. }
  188. return JsonResponse(data)
  189. class AudioScrobblerImportCreateView(
  190. LoginRequiredMixin, JsonableResponseMixin, CreateView
  191. ):
  192. model = AudioScrobblerTSVImport
  193. fields = ['tsv_file']
  194. template_name = 'scrobbles/upload_form.html'
  195. success_url = reverse_lazy('vrobbler-home')
  196. def form_valid(self, form):
  197. self.object = form.save(commit=False)
  198. self.object.user = self.request.user
  199. self.object.save()
  200. process_tsv_import.delay(self.object.id)
  201. return HttpResponseRedirect(self.get_success_url())
  202. class KoReaderImportCreateView(
  203. LoginRequiredMixin, JsonableResponseMixin, CreateView
  204. ):
  205. model = KoReaderImport
  206. fields = ['sqlite_file']
  207. template_name = 'scrobbles/upload_form.html'
  208. success_url = reverse_lazy('vrobbler-home')
  209. def form_valid(self, form):
  210. self.object = form.save(commit=False)
  211. self.object.user = self.request.user
  212. self.object.save()
  213. process_koreader_import.delay(self.object.id)
  214. return HttpResponseRedirect(self.get_success_url())
  215. @permission_classes([IsAuthenticated])
  216. @api_view(['GET'])
  217. def lastfm_import(request):
  218. lfm_import, created = LastFmImport.objects.get_or_create(
  219. user=request.user, processed_finished__isnull=True
  220. )
  221. process_lastfm_import.delay(lfm_import.id)
  222. success_url = reverse_lazy('vrobbler-home')
  223. return HttpResponseRedirect(success_url)
  224. @csrf_exempt
  225. @permission_classes([IsAuthenticated])
  226. @api_view(['POST'])
  227. def jellyfin_webhook(request):
  228. data_dict = request.data
  229. if (
  230. data_dict['NotificationType'] == 'PlaybackProgress'
  231. and data_dict['ItemType'] == 'Audio'
  232. ):
  233. return Response({}, status=status.HTTP_304_NOT_MODIFIED)
  234. # For making things easier to build new input processors
  235. if getattr(settings, "DUMP_REQUEST_DATA", False):
  236. json_data = json.dumps(data_dict, indent=4)
  237. logger.debug(f"{json_data}")
  238. scrobble = None
  239. media_type = data_dict.get("ItemType", "")
  240. if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
  241. scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
  242. if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
  243. scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
  244. if not scrobble:
  245. return Response({}, status=status.HTTP_400_BAD_REQUEST)
  246. return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
  247. @csrf_exempt
  248. @permission_classes([IsAuthenticated])
  249. @api_view(['POST'])
  250. def mopidy_webhook(request):
  251. try:
  252. data_dict = json.loads(request.data)
  253. except TypeError:
  254. logger.warning('Received Mopidy data as dict, rather than a string')
  255. data_dict = request.data
  256. # For making things easier to build new input processors
  257. if getattr(settings, "DUMP_REQUEST_DATA", False):
  258. json_data = json.dumps(data_dict, indent=4)
  259. logger.debug(f"{json_data}")
  260. if 'podcast' in data_dict.get('mopidy_uri'):
  261. scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
  262. else:
  263. scrobble = mopidy_scrobble_track(data_dict, request.user.id)
  264. if not scrobble:
  265. return Response({}, status=status.HTTP_400_BAD_REQUEST)
  266. return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
  267. @csrf_exempt
  268. @permission_classes([IsAuthenticated])
  269. @api_view(['POST'])
  270. @parser_classes([MultiPartParser])
  271. def import_audioscrobbler_file(request):
  272. """Takes a TSV file in the Audioscrobbler format, saves it and processes the
  273. scrobbles.
  274. """
  275. scrobbles_created = []
  276. # tsv_file = request.FILES[0]
  277. file_serializer = serializers.AudioScrobblerTSVImportSerializer(
  278. data=request.data
  279. )
  280. if file_serializer.is_valid():
  281. import_file = file_serializer.save()
  282. return Response(
  283. {'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK
  284. )
  285. else:
  286. return Response(
  287. file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
  288. )
  289. @permission_classes([IsAuthenticated])
  290. @api_view(['GET'])
  291. def scrobble_finish(request, uuid):
  292. user = request.user
  293. success_url = reverse_lazy('vrobbler-home')
  294. if not user.is_authenticated:
  295. return HttpResponseRedirect(success_url)
  296. scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
  297. if scrobble:
  298. scrobble.stop(force_finish=True)
  299. messages.add_message(
  300. request,
  301. messages.SUCCESS,
  302. f"Scrobble of {scrobble.media_obj} finished.",
  303. )
  304. else:
  305. messages.add_message(request, messages.ERROR, "Scrobble not found.")
  306. return HttpResponseRedirect(success_url)
  307. @permission_classes([IsAuthenticated])
  308. @api_view(['GET'])
  309. def scrobble_cancel(request, uuid):
  310. user = request.user
  311. success_url = reverse_lazy('vrobbler-home')
  312. if not user.is_authenticated:
  313. return HttpResponseRedirect(success_url)
  314. scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
  315. if scrobble:
  316. scrobble.cancel()
  317. messages.add_message(
  318. request,
  319. messages.SUCCESS,
  320. f"Scrobble of {scrobble.media_obj} cancelled.",
  321. )
  322. else:
  323. messages.add_message(request, messages.ERROR, "Scrobble not found.")
  324. return HttpResponseRedirect(success_url)
  325. @permission_classes([IsAuthenticated])
  326. @api_view(['GET'])
  327. def export(request):
  328. format = request.GET.get('export_type', 'csv')
  329. start = request.GET.get('start')
  330. end = request.GET.get('end')
  331. logger.debug(f"Exporting all scrobbles in format {format}")
  332. temp_file, extension = export_scrobbles(
  333. start_date=start, end_date=end, format=format
  334. )
  335. now = datetime.now()
  336. filename = f"vrobbler-export-{str(now)}.{extension}"
  337. response = FileResponse(open(temp_file, 'rb'))
  338. response["Content-Disposition"] = f'attachment; filename="{filename}"'
  339. return response
  340. class ChartRecordView(TemplateView):
  341. template_name = 'scrobbles/chart_index.html'
  342. @staticmethod
  343. def get_media_filter(media_type: str = "") -> Q:
  344. filters = {
  345. "Track": Q(track__isnull=False),
  346. "Artist": Q(artist__isnull=False),
  347. "Series": Q(series__isnull=False),
  348. "Video": Q(video__isnull=False),
  349. "": Q(),
  350. }
  351. return filters[media_type]
  352. def get_chart_records(self, media_type: str = "", **kwargs):
  353. media_filter = self.get_media_filter(media_type)
  354. charts = ChartRecord.objects.filter(
  355. media_filter, user=self.request.user, **kwargs
  356. ).order_by("rank")
  357. if charts.count() == 0:
  358. ChartRecord.build(
  359. user=self.request.user, model_str=media_type, **kwargs
  360. )
  361. charts = ChartRecord.objects.filter(
  362. media_filter, user=self.request.user, **kwargs
  363. ).order_by("rank")
  364. return charts
  365. def get_chart(
  366. self, period: str = "all_time", limit=15, media: str = ""
  367. ) -> QuerySet:
  368. now = timezone.now()
  369. params = {}
  370. params['media_type'] = media
  371. if period == "today":
  372. params['day'] = now.day
  373. params['month'] = now.month
  374. params['year'] = now.year
  375. if period == "week":
  376. params['week'] = now.ioscalendar()[1]
  377. params['year'] = now.year
  378. if period == "month":
  379. params['month'] = now.month
  380. params['year'] = now.year
  381. if period == "year":
  382. params['year'] = now.year
  383. return self.get_chart_records(**params)[:limit]
  384. def get_context_data(self, **kwargs):
  385. context_data = super().get_context_data(**kwargs)
  386. date = self.request.GET.get("date")
  387. media_type = self.request.GET.get("media", "Track")
  388. user = self.request.user
  389. params = {}
  390. context_data["artist_charts"] = {}
  391. if not date:
  392. artist_params = {'user': user, 'media_type': 'Artist'}
  393. context_data['current_artist_charts'] = {
  394. "today": live_charts(**artist_params, chart_period="today"),
  395. "week": live_charts(**artist_params, chart_period="week"),
  396. "month": live_charts(**artist_params, chart_period="month"),
  397. "all": live_charts(**artist_params),
  398. }
  399. track_params = {'user': user, 'media_type': 'Track'}
  400. context_data['current_track_charts'] = {
  401. "today": live_charts(**track_params, chart_period="today"),
  402. "week": live_charts(**track_params, chart_period="week"),
  403. "month": live_charts(**track_params, chart_period="month"),
  404. "all": live_charts(**track_params),
  405. }
  406. return context_data
  407. # Date provided, lookup past charts, returning nothing if it's now or in the future.
  408. now = timezone.now()
  409. year = now.year
  410. params = {'year': year}
  411. name = f"Chart for {year}"
  412. date_params = date.split('-')
  413. year = int(date_params[0])
  414. in_progress = False
  415. if len(date_params) == 2:
  416. if 'W' in date_params[1]:
  417. week = int(date_params[1].strip('W"'))
  418. params['week'] = week
  419. start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace(
  420. tzinfo=pytz.utc
  421. )
  422. end = start + timedelta(days=6)
  423. in_progress = start <= now <= end
  424. as_str = start.strftime('Week of %B %d, %Y')
  425. name = f"Chart for {as_str}"
  426. else:
  427. month = int(date_params[1])
  428. params['month'] = month
  429. month_str = calendar.month_name[month]
  430. name = f"Chart for {month_str} {year}"
  431. in_progress = now.month == month and now.year == year
  432. if len(date_params) == 3:
  433. month = int(date_params[1])
  434. day = int(date_params[2])
  435. params['month'] = month
  436. params['day'] = day
  437. month_str = calendar.month_name[month]
  438. name = f"Chart for {month_str} {day}, {year}"
  439. in_progress = (
  440. now.month == month and now.year == year and now.day == day
  441. )
  442. media_filter = self.get_media_filter("Track")
  443. track_charts = ChartRecord.objects.filter(
  444. media_filter, user=self.request.user, **params
  445. ).order_by("rank")
  446. media_filter = self.get_media_filter("Artist")
  447. artist_charts = ChartRecord.objects.filter(
  448. media_filter, user=self.request.user, **params
  449. ).order_by("rank")
  450. if track_charts.count() == 0 and not in_progress:
  451. ChartRecord.build(
  452. user=self.request.user, model_str="Track", **params
  453. )
  454. media_filter = self.get_media_filter("Track")
  455. track_charts = ChartRecord.objects.filter(
  456. media_filter, user=self.request.user, **params
  457. ).order_by("rank")
  458. if artist_charts.count() == 0 and not in_progress:
  459. ChartRecord.build(
  460. user=self.request.user, model_str="Artist", **params
  461. )
  462. media_filter = self.get_media_filter("Artist")
  463. artist_charts = ChartRecord.objects.filter(
  464. media_filter, user=self.request.user, **params
  465. ).order_by("rank")
  466. context_data['media_type'] = media_type
  467. context_data['track_charts'] = track_charts
  468. context_data['artist_charts'] = artist_charts
  469. context_data['name'] = " ".join(["Top", media_type, "for", name])
  470. context_data['in_progress'] = in_progress
  471. return context_data