Add exporting and importing scrobbles

This commit is contained in:
2023-02-04 17:08:01 -05:00
parent 4c434aeb7c
commit 9d303b1b94
9 changed files with 228 additions and 26 deletions

View File

@ -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

View File

@ -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):

View File

@ -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,

View File

@ -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()

View File

@ -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'),
]

View File

@ -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

View File

@ -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>

View File

@ -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 %}

View File

@ -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: