From 9d303b1b94a8826700b90064cab761bcf5281ca4 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 4 Feb 2023 17:08:01 -0500 Subject: [PATCH] Add exporting and importing scrobbles --- vrobbler/apps/scrobbles/export.py | 64 ++++++++++++++++ vrobbler/apps/scrobbles/forms.py | 13 ++-- vrobbler/apps/scrobbles/models.py | 7 +- vrobbler/apps/scrobbles/tsv.py | 19 +++++ vrobbler/apps/scrobbles/urls.py | 5 +- vrobbler/apps/scrobbles/views.py | 65 +++++++++++++++-- vrobbler/templates/base.html | 2 +- .../templates/scrobbles/scrobble_list.html | 73 ++++++++++++++++--- vrobbler/urls.py | 6 +- 9 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 vrobbler/apps/scrobbles/export.py diff --git a/vrobbler/apps/scrobbles/export.py b/vrobbler/apps/scrobbles/export.py new file mode 100644 index 0000000..67cdce7 --- /dev/null +++ b/vrobbler/apps/scrobbles/export.py @@ -0,0 +1,64 @@ +import csv +import tempfile +from scrobbles.models import Scrobble + +from django.db.models import Q + + +def export_scrobbles(start_date=None, end_date=None, format="AS"): + start_query = Q() + end_query = Q() + if start_date: + start_query = Q(timestamp__gte=start_date) + if start_date: + end_query = Q(timestamp__lte=end_date) + + scrobble_qs = Scrobble.objects.filter(start_query, end_query) + headers = [] + extension = 'tsv' + delimiter = '\t' + + if format == "as": + headers = [ + ['#AUDIOSCROBBLER/1.1'], + ['#TZ/UTC'], + ['#CLIENT/Vrobbler 1.0.0'], + ] + + if format == "csv": + delimiter = ',' + extension = 'csv' + headers = [ + [ + "artists", + "album", + "title", + "track_number", + "run_time", + "rating", + "timestamp", + "musicbrainz_id", + ] + ] + + with tempfile.NamedTemporaryFile(mode='w', delete=False) as outfile: + writer = csv.writer(outfile, delimiter=delimiter) + for row in headers: + writer.writerow(row) + + for scrobble in scrobble_qs: + track = scrobble.track + track_number = 0 # TODO Add track number + track_rating = "S" # TODO implement ratings? + row = [ + track.album.primary_artist.name, + track.album.name, + track.title, + track_number, + track.run_time, + track_rating, + scrobble.timestamp.strftime('%s'), + track.musicbrainz_id, + ] + writer.writerow(row) + return outfile.name, extension diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py index e33cc16..40c2c39 100644 --- a/vrobbler/apps/scrobbles/forms.py +++ b/vrobbler/apps/scrobbles/forms.py @@ -1,12 +1,15 @@ from django import forms -from scrobbles.models import AudioScrobblerTSVImport +class ExportScrobbleForm(forms.Form): + """Provide options for downloading scrobbles""" -class UploadAudioscrobblerFileForm(forms.ModelForm): - class Meta: - model = AudioScrobblerTSVImport - fields = ('tsv_file',) + EXPORT_TYPES = ( + ('as', 'Audioscrobbler'), + ('csv', 'CSV'), + ('html', 'HTML'), + ) + export_type = forms.ChoiceField(choices=EXPORT_TYPES) class ScrobbleForm(forms.Form): diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 8ae7d97..ad31d70 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -51,7 +51,7 @@ class AudioScrobblerTSVImport(TimeStampedModel): if scrobbles: self.process_log = f"Created {len(scrobbles)} scrobbles" for scrobble in scrobbles: - scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}\t" + scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}" self.process_log += f"\n{scrobble_str}" self.process_count = len(scrobbles) else: @@ -63,6 +63,11 @@ class AudioScrobblerTSVImport(TimeStampedModel): update_fields=['processed_on', 'process_count', 'process_log'] ) + def undo(self, dryrun=True): + from scrobbles.tsv import undo_audioscrobbler_tsv_import + + undo_audioscrobbler_tsv_import(self.process_log, dryrun) + class ChartRecord(TimeStampedModel): """Sort of like a materialized view for what we could dynamically generate, diff --git a/vrobbler/apps/scrobbles/tsv.py b/vrobbler/apps/scrobbles/tsv.py index 8483983..ccf7d85 100644 --- a/vrobbler/apps/scrobbles/tsv.py +++ b/vrobbler/apps/scrobbles/tsv.py @@ -97,3 +97,22 @@ def process_audioscrobbler_tsv_file(file_path): extra={'created_scrobbles': created}, ) return created + + +def undo_audioscrobbler_tsv_import(process_log, dryrun=True): + """Accepts the log from a TSV import and removes the scrobbles""" + if not process_log: + logger.warning("No lines in process log found to undo") + return + + for line_num, line in enumerate(process_log.split('\n')): + if line_num == 0: + continue + scrobble_id = line.split("\t")[0] + scrobble = Scrobble.objects.filter(id=scrobble_id).first() + if not scrobble: + logger.warning(f"Could not find scrobble {scrobble_id} to undo") + continue + logger.info(f"Removing scrobble {scrobble_id}") + if not dryrun: + scrobble.delete() diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index bad4d8f..8353301 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -8,10 +8,11 @@ urlpatterns = [ path('finish/', views.scrobble_finish, name='finish'), path('cancel/', views.scrobble_cancel, name='cancel'), path( - 'audioscrobbler-file-upload/', - views.import_audioscrobbler_file, + 'upload/', + views.AudioScrobblerImportCreateView.as_view(), name='audioscrobbler-file-upload', ), path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'), path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'), + path('export/', views.export, name='export'), ] diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 3e2c3f2..35fb07e 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -1,14 +1,16 @@ import json import logging +from datetime import datetime import pytz from django.conf import settings from django.db.models.fields import timezone -from django.http import HttpResponseRedirect -from django.urls import reverse +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 FormView +from django.views.generic.edit import CreateView from django.views.generic.list import ListView from rest_framework import status from rest_framework.decorators import ( @@ -23,9 +25,9 @@ from scrobbles.constants import ( JELLYFIN_AUDIO_ITEM_TYPES, JELLYFIN_VIDEO_ITEM_TYPES, ) -from scrobbles.forms import ScrobbleForm, UploadAudioscrobblerFileForm +from scrobbles.forms import ExportScrobbleForm, ScrobbleForm from scrobbles.imdb import lookup_video_from_imdb -from scrobbles.models import Scrobble +from scrobbles.models import AudioScrobblerTSVImport, Scrobble from scrobbles.scrobblers import ( jellyfin_scrobble_track, jellyfin_scrobble_video, @@ -46,6 +48,7 @@ from vrobbler.apps.music.aggregators import ( top_tracks, week_of_scrobbles, ) +from vrobbler.apps.scrobbles.export import export_scrobbles logger = logging.getLogger(__name__) @@ -95,6 +98,7 @@ class RecentScrobbleList(ListView): data['counts'] = scrobble_counts(user) data['imdb_form'] = ScrobbleForm + data['export_form'] = ExportScrobbleForm return data def get_queryset(self): @@ -129,9 +133,38 @@ class ManualScrobbleView(FormView): return HttpResponseRedirect(reverse("home")) -class AudioScrobblerUploadView(FormView): - form_class = UploadAudioscrobblerFileForm +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(JsonableResponseMixin, CreateView): + model = AudioScrobblerTSVImport + fields = ['tsv_file'] template_name = 'scrobbles/upload_form.html' + success_url = reverse_lazy('vrobbler-home') @csrf_exempt @@ -251,3 +284,23 @@ def scrobble_cancel(request, uuid): return Response( {'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK ) + + +@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 diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.html index e2375ca..0981798 100644 --- a/vrobbler/templates/base.html +++ b/vrobbler/templates/base.html @@ -271,7 +271,6 @@ {% endblock %} - {% block extra_js %} + {% block extra_js %} {% endblock %} diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index f17ad62..1030f1b 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -8,18 +8,17 @@

Dashboard

- - + +
@@ -267,9 +266,65 @@
- {% else %} - {% endif %} + + + + +{% endblock %} + +{% block extra_js %} + {% endblock %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 43af0da..8bf883d 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -21,12 +21,14 @@ urlpatterns = [ ), path( 'manual/audioscrobbler/', - scrobbles_views.AudioScrobblerUploadView.as_view(), + scrobbles_views.AudioScrobblerImportCreateView.as_view(), name='audioscrobbler-file-upload', ), path("", include(music_urls, namespace="music")), path("", include(video_urls, namespace="videos")), - path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"), + path( + "", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home" + ), ] if settings.DEBUG: