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
|
||||
|
||||
Reference in New Issue
Block a user