Add import of Audioscrobbler files
Here we add a model for holding Audioscrobbler imports and some code to process the tab-separated files we get from Rockbox.
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleInline(admin.TabularInline):
|
class ScrobbleInline(admin.TabularInline):
|
||||||
@ -10,6 +10,13 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
exclude = ('source_id', 'scrobble_log')
|
exclude = ('source_id', 'scrobble_log')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AudioScrobblerTSVImport)
|
||||||
|
class AudioScrobblerTSVImportAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("id", "tsv_file", "created")
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Scrobble)
|
@admin.register(Scrobble)
|
||||||
class ScrobbleAdmin(admin.ModelAdmin):
|
class ScrobbleAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "timestamp"
|
date_hierarchy = "timestamp"
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
|
from scrobbles.models import AudioScrobblerTSVImport
|
||||||
|
|
||||||
|
|
||||||
|
class UploadAudioscrobblerFileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
fields = ('tsv_file',)
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleForm(forms.Form):
|
class ScrobbleForm(forms.Form):
|
||||||
item_id = forms.CharField(
|
item_id = forms.CharField(
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 19:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0011_chartrecord_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AudioScrobblerTSVImport',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'created',
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name='created'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'modified',
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name='modified'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'tsv_file',
|
||||||
|
models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to='audioscrobbler-uploads/%Y/%m-%d/',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'get_latest_by': 'modified',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0012_audioscrobblertsvimport'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='processed_on',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import calendar
|
import calendar
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -12,13 +11,38 @@ from podcasts.models import Episode
|
|||||||
from scrobbles.utils import check_scrobble_for_finish
|
from scrobbles.utils import check_scrobble_for_finish
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from videos.models import Series, Video
|
from videos.models import Series, Video
|
||||||
from vrobbler.apps.profiles.utils import now_user_timezone
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImport(TimeStampedModel):
|
||||||
|
tsv_file = models.FileField(
|
||||||
|
upload_to="audioscrobbler-uploads/%Y/%m-%d/", **BNULL
|
||||||
|
)
|
||||||
|
processed_on = models.DateTimeField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Audioscrobbler TSV upload: {self.tsv_file.path}"
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""On save, attempt to import the TSV file"""
|
||||||
|
|
||||||
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from scrobbles.tsv import process_audioscrobbler_tsv_file
|
||||||
|
|
||||||
|
if self.processed_on and not force:
|
||||||
|
logger.info(f"{self} already processed on {self.processed_on}")
|
||||||
|
return
|
||||||
|
|
||||||
|
process_audioscrobbler_tsv_file(self.tsv_file.path)
|
||||||
|
self.processed_on = timezone.now()
|
||||||
|
self.save(update_fields=['processed_on'])
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
but would kill the DB as it gets larger. Collects time-based records
|
but would kill the DB as it gets larger. Collects time-based records
|
||||||
|
|||||||
98
vrobbler/apps/scrobbles/tsv.py
Normal file
98
vrobbler/apps/scrobbles/tsv.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from music.models import Album, Artist, Track
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_audioscrobbler_tsv_file(file_path):
|
||||||
|
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
|
||||||
|
new_scrobbles = []
|
||||||
|
|
||||||
|
with open(file_path) as infile:
|
||||||
|
source = 'Audioscrobbler File'
|
||||||
|
rows = csv.reader(infile, delimiter="\t")
|
||||||
|
|
||||||
|
source_id = ""
|
||||||
|
for row_num, row in enumerate(rows):
|
||||||
|
if row_num in [0, 1, 2]:
|
||||||
|
source_id += row[0] + "\n"
|
||||||
|
continue
|
||||||
|
if len(row) > 8:
|
||||||
|
logger.warning(
|
||||||
|
'Improper row length during Audioscrobbler import',
|
||||||
|
extra={'row': row},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
artist, artist_created = Artist.objects.get_or_create(name=row[0])
|
||||||
|
if artist_created:
|
||||||
|
logger.debug(f"Created artist {artist}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Found artist {artist}")
|
||||||
|
|
||||||
|
album = None
|
||||||
|
album_created = False
|
||||||
|
albums = Album.objects.filter(name=row[1])
|
||||||
|
if albums.count() == 1:
|
||||||
|
album = albums.first()
|
||||||
|
else:
|
||||||
|
for potential_album in albums:
|
||||||
|
if artist in album.artist_set.all():
|
||||||
|
album = potential_album
|
||||||
|
if not album:
|
||||||
|
album_created = True
|
||||||
|
album = Album.objects.create(name=row[1])
|
||||||
|
album.save()
|
||||||
|
album.artists.add(artist)
|
||||||
|
|
||||||
|
if album_created:
|
||||||
|
logger.debug(f"Created album {album}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Found album {album}")
|
||||||
|
|
||||||
|
track, track_created = Track.objects.get_or_create(
|
||||||
|
title=row[2],
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
)
|
||||||
|
|
||||||
|
if track_created:
|
||||||
|
logger.debug(f"Created track {track}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Found track {track}")
|
||||||
|
|
||||||
|
if track_created:
|
||||||
|
track.musicbrainz_id = row[7]
|
||||||
|
track.save()
|
||||||
|
|
||||||
|
timestamp = datetime.utcfromtimestamp(int(row[6])).replace(
|
||||||
|
tzinfo=pytz.utc
|
||||||
|
)
|
||||||
|
source = 'Audioscrobbler File'
|
||||||
|
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
timestamp=timestamp,
|
||||||
|
source=source,
|
||||||
|
source_id=source_id,
|
||||||
|
track=track,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
)
|
||||||
|
existing = Scrobble.objects.filter(
|
||||||
|
timestamp=timestamp, track=track
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
||||||
|
continue
|
||||||
|
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
||||||
|
new_scrobbles.append(new_scrobble)
|
||||||
|
|
||||||
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
logger.info(
|
||||||
|
f"Created {len(created)} scrobbles",
|
||||||
|
extra={'created_scrobbles': created},
|
||||||
|
)
|
||||||
@ -18,7 +18,7 @@ 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
|
from scrobbles.forms import ScrobbleForm, UploadAudioscrobblerFileForm
|
||||||
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 Scrobble
|
||||||
from scrobbles.scrobblers import (
|
from scrobbles.scrobblers import (
|
||||||
@ -121,6 +121,11 @@ class ManualScrobbleView(FormView):
|
|||||||
return HttpResponseRedirect(reverse("home"))
|
return HttpResponseRedirect(reverse("home"))
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerUploadView(FormView):
|
||||||
|
form_class = UploadAudioscrobblerFileForm
|
||||||
|
template_name = 'scrobbles/upload_form.html'
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def scrobble_endpoint(request):
|
def scrobble_endpoint(request):
|
||||||
|
|||||||
13
vrobbler/templates/scrobbles/upload_form.html
Normal file
13
vrobbler/templates/scrobbles/upload_form.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||||
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||||
|
<h1 class="h2">Manual scrobble</h1>
|
||||||
|
<form action="{% url 'audioscrobbler-file-upload' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@ -19,6 +19,11 @@ urlpatterns = [
|
|||||||
scrobbles_views.ManualScrobbleView.as_view(),
|
scrobbles_views.ManualScrobbleView.as_view(),
|
||||||
name='imdb-manual-scrobble',
|
name='imdb-manual-scrobble',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'manual/audioscrobbler/',
|
||||||
|
scrobbles_views.AudioScrobblerUploadView.as_view(),
|
||||||
|
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="home"),
|
||||||
|
|||||||
Reference in New Issue
Block a user