Add exporting and importing scrobbles
This commit is contained in:
64
vrobbler/apps/scrobbles/export.py
Normal file
64
vrobbler/apps/scrobbles/export.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">×</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">×</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 %}
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user