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 django import forms
|
||||||
|
|
||||||
from scrobbles.models import AudioScrobblerTSVImport
|
|
||||||
|
|
||||||
|
class ExportScrobbleForm(forms.Form):
|
||||||
|
"""Provide options for downloading scrobbles"""
|
||||||
|
|
||||||
class UploadAudioscrobblerFileForm(forms.ModelForm):
|
EXPORT_TYPES = (
|
||||||
class Meta:
|
('as', 'Audioscrobbler'),
|
||||||
model = AudioScrobblerTSVImport
|
('csv', 'CSV'),
|
||||||
fields = ('tsv_file',)
|
('html', 'HTML'),
|
||||||
|
)
|
||||||
|
export_type = forms.ChoiceField(choices=EXPORT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleForm(forms.Form):
|
class ScrobbleForm(forms.Form):
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class AudioScrobblerTSVImport(TimeStampedModel):
|
|||||||
if scrobbles:
|
if scrobbles:
|
||||||
self.process_log = f"Created {len(scrobbles)} scrobbles"
|
self.process_log = f"Created {len(scrobbles)} scrobbles"
|
||||||
for scrobble in 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_log += f"\n{scrobble_str}"
|
||||||
self.process_count = len(scrobbles)
|
self.process_count = len(scrobbles)
|
||||||
else:
|
else:
|
||||||
@ -63,6 +63,11 @@ class AudioScrobblerTSVImport(TimeStampedModel):
|
|||||||
update_fields=['processed_on', 'process_count', 'process_log']
|
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):
|
class ChartRecord(TimeStampedModel):
|
||||||
"""Sort of like a materialized view for what we could dynamically generate,
|
"""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},
|
extra={'created_scrobbles': created},
|
||||||
)
|
)
|
||||||
return 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('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
||||||
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
||||||
path(
|
path(
|
||||||
'audioscrobbler-file-upload/',
|
'upload/',
|
||||||
views.import_audioscrobbler_file,
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
name='audioscrobbler-file-upload',
|
name='audioscrobbler-file-upload',
|
||||||
),
|
),
|
||||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
||||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
||||||
|
path('export/', views.export, name='export'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models.fields import timezone
|
from django.db.models.fields import timezone
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
from django.views.generic.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import (
|
from rest_framework.decorators import (
|
||||||
@ -23,9 +25,9 @@ from scrobbles.constants import (
|
|||||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||||
JELLYFIN_VIDEO_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.imdb import lookup_video_from_imdb
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||||
from scrobbles.scrobblers import (
|
from scrobbles.scrobblers import (
|
||||||
jellyfin_scrobble_track,
|
jellyfin_scrobble_track,
|
||||||
jellyfin_scrobble_video,
|
jellyfin_scrobble_video,
|
||||||
@ -46,6 +48,7 @@ from vrobbler.apps.music.aggregators import (
|
|||||||
top_tracks,
|
top_tracks,
|
||||||
week_of_scrobbles,
|
week_of_scrobbles,
|
||||||
)
|
)
|
||||||
|
from vrobbler.apps.scrobbles.export import export_scrobbles
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -95,6 +98,7 @@ class RecentScrobbleList(ListView):
|
|||||||
|
|
||||||
data['counts'] = scrobble_counts(user)
|
data['counts'] = scrobble_counts(user)
|
||||||
data['imdb_form'] = ScrobbleForm
|
data['imdb_form'] = ScrobbleForm
|
||||||
|
data['export_form'] = ExportScrobbleForm
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -129,9 +133,38 @@ class ManualScrobbleView(FormView):
|
|||||||
return HttpResponseRedirect(reverse("home"))
|
return HttpResponseRedirect(reverse("home"))
|
||||||
|
|
||||||
|
|
||||||
class AudioScrobblerUploadView(FormView):
|
class JsonableResponseMixin:
|
||||||
form_class = UploadAudioscrobblerFileForm
|
"""
|
||||||
|
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'
|
template_name = 'scrobbles/upload_form.html'
|
||||||
|
success_url = reverse_lazy('vrobbler-home')
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ -251,3 +284,23 @@ def scrobble_cancel(request, uuid):
|
|||||||
return Response(
|
return Response(
|
||||||
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
|
{'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 %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</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 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 ------------------------------------------------->
|
<script><!-- comment ------------------------------------------------->
|
||||||
/* globals Chart:false, feather:false */
|
/* globals Chart:false, feather:false */
|
||||||
@ -320,6 +319,7 @@
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
{% block extra_js %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -8,18 +8,17 @@
|
|||||||
<h1 class="h2">Dashboard</h1>
|
<h1 class="h2">Dashboard</h1>
|
||||||
<div class="btn-toolbar mb-2 mb-md-0">
|
<div class="btn-toolbar mb-2 mb-md-0">
|
||||||
<div class="btn-group me-2">
|
<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" data-bs-toggle="modal" data-bs-target="#importModal">Import</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="#exportModal">Export</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown">
|
<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>
|
<span data-feather="calendar"></span>
|
||||||
This week
|
This week
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" aria-labelledby="graphDateButton">
|
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
|
||||||
<a class="dropdown-item" href="#">Action</a>
|
<a class="dropdown-item" href="#">This month</a>
|
||||||
<a class="dropdown-item" href="#">Another action</a>
|
<a class="dropdown-item" href="#">This year</a>
|
||||||
<a class="dropdown-item" href="#">Something else here</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -267,9 +266,65 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@ -21,12 +21,14 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
'manual/audioscrobbler/',
|
'manual/audioscrobbler/',
|
||||||
scrobbles_views.AudioScrobblerUploadView.as_view(),
|
scrobbles_views.AudioScrobblerImportCreateView.as_view(),
|
||||||
name='audioscrobbler-file-upload',
|
name='audioscrobbler-file-upload',
|
||||||
),
|
),
|
||||||
path("", include(music_urls, namespace="music")),
|
path("", include(music_urls, namespace="music")),
|
||||||
path("", include(video_urls, namespace="videos")),
|
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:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user