Przeglądaj źródła

Add exporting and importing scrobbles

Colin Powell 2 lat temu
rodzic
commit
9d303b1b94

+ 64 - 0
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

+ 8 - 5
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):

+ 6 - 1
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,

+ 19 - 0
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()

+ 3 - 2
vrobbler/apps/scrobbles/urls.py

@@ -8,10 +8,11 @@ urlpatterns = [
     path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
     path('cancel/<slug:uuid>', 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'),
 ]

+ 59 - 6
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

+ 1 - 1
vrobbler/templates/base.html

@@ -271,7 +271,6 @@
                 {% endblock %}
             </div>
         </div>
-        {% block extra_js %}
         <script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
         <script><!-- comment ------------------------------------------------->
             /* globals Chart:false, feather:false */
@@ -320,6 +319,7 @@
             })()
 
         </script>
+        {% block extra_js %}
         {% endblock %}
     </body>
 </html>

+ 64 - 9
vrobbler/templates/scrobbles/scrobble_list.html

@@ -8,18 +8,17 @@
         <h1 class="h2">Dashboard</h1>
         <div class="btn-toolbar mb-2 mb-md-0">
             <div class="btn-group me-2">
-                <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
-                <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
+                <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importModal">Import</button>
+                <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#exportModal">Export</button>
             </div>
             <div class="dropdown">
-                <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                     <span data-feather="calendar"></span>
                     This week
                 </button>
-                <div class="dropdown-menu" aria-labelledby="graphDateButton">
-                    <a class="dropdown-item" href="#">Action</a>
-                    <a class="dropdown-item" href="#">Another action</a>
-                    <a class="dropdown-item" href="#">Something else here</a>
+                <div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
+                    <a class="dropdown-item" href="#">This month</a>
+                    <a class="dropdown-item" href="#">This year</a>
                 </div>
             </div>
         </div>
@@ -267,9 +266,65 @@
 
             </div>
         </div>
-        {% else %}
-
         {% endif %}
     </div>
 </main>
+
+<div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="importModalLabel" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="importModalLabel">Import scrobbles</h5>
+                <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form action="{% url 'audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
+                <div class="modal-body">
+                        {% csrf_token %}
+                        <div class="form-group">
+                            <label for="tsv_file" class="col-form-label">Audioscrobbler TSV file:</label>
+                            <input type="file" name="tsv_file" class="form-control" id="id_tsv_file">
+                        </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="submit" class="btn btn-primary">Import</button>
+                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="exportModalLabel">Export scrobbles</h5>
+                <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form action="{% url 'scrobbles:export' %}" method="get">
+                <div class="modal-body">
+                        {% csrf_token %}
+                        <div class="form-group">
+                            {{export_form.as_div}}
+                        </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="submit" class="btn btn-primary">Export</button>
+                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+$('#importModal').on('shown.bs.modal', function () { $('#importInput').trigger('focus') });
+$('#exportModal').on('shown.bs.modal', function () { $('#exportInput').trigger('focus') });
+</script>
 {% endblock %}

+ 4 - 2
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: