views.py 20 KB

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