Compare commits

...

18 Commits

Author SHA1 Message Date
ab728de75f Bump version to 0.10.2 2023-02-23 11:24:33 -05:00
04b7214795 Fix jellyfin scrobbling 2023-02-23 11:23:01 -05:00
479fee6a5c Its a webhook, not a websocket 2023-02-23 11:03:19 -05:00
40a126cf8b Add sportsdb event id to scrobbles 2023-02-23 10:59:58 -05:00
83c02aa00f Oops, need to move webhook urls around 2023-02-23 10:56:36 -05:00
0f44df2b9b Add subtitle field to media objects 2023-02-23 10:56:21 -05:00
16d1dcc125 Fix column flow on main page 2023-02-23 10:56:03 -05:00
927d0be1b8 Bump version to 0.10.1 2023-02-23 10:14:41 -05:00
f6b9245b8b Add looking tracks without MB IDs by looking them up 2023-02-23 10:07:29 -05:00
39e035b460 Clean up URLs and templates 2023-02-21 00:17:31 -05:00
cf9da39967 Update API to be more complete 2023-02-20 17:08:54 -05:00
2e98850494 Bump version to 0.10.0 2023-02-20 02:06:53 -05:00
5d315b4834 Fix LastFM and add UI for KoReader 2023-02-20 02:06:17 -05:00
6ef8238442 Add book scrobbling 2023-02-19 22:19:01 -05:00
f4a444354d Fix lastfm import errors 2023-02-19 22:18:11 -05:00
0db5bbe36c Fix users not being added to tsv and lastfm imports 2023-02-17 14:05:06 -05:00
69b6364f88 Update todos and add Procfile/honcho 2023-02-17 13:57:50 -05:00
966aeefbdd Turns out I shoulda tested this more 2023-02-17 13:25:56 -05:00
53 changed files with 1282 additions and 273 deletions

2
Procfile Normal file
View File

@ -0,0 +1,2 @@
web: python manage.py runserver 0.0.0.0:8014
worker: celery -A vrobbler worker -l DEBUG

20
poetry.lock generated
View File

@ -587,6 +587,20 @@ category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "honcho"
version = "1.1.0"
description = "Honcho: a Python clone of Foreman. For managing Procfile-based applications."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
export = ["jinja2 (>=2.7,<3)"]
[[package]]
name = "httpcore"
version = "0.16.3"
@ -1598,7 +1612,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "4b71b291b00a768d7d3c253a02faf70249d1f10ba85fcb88fc5c80fecb412332"
content-hash = "d57d0a79f04c3288d12d8a9fb3579e03fa514d1a130b11c28812150feeb66a06"
[metadata.files]
amqp = [
@ -2079,6 +2093,10 @@ h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
honcho = [
{file = "honcho-1.1.0-py2.py3-none-any.whl", hash = "sha256:a4d6e3a88a7b51b66351ecfc6e9d79d8f4b87351db9ad7e923f5632cc498122f"},
{file = "honcho-1.1.0.tar.gz", hash = "sha256:c5eca0bded4bef6697a23aec0422fd4f6508ea3581979a3485fc4b89357eb2a9"},
]
httpcore = [
{file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"},
{file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.9.3"
version = "0.10.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -35,6 +35,7 @@ django-redis = "^5.2.0"
pylast = "^5.1.0"
django-encrypted-field = "^1.0.5"
celery = "^5.2.7"
honcho = "^1.1.0"
[tool.poetry.dev-dependencies]
Werkzeug = "2.0.3"

View File

@ -16,7 +16,7 @@ from scrobbles.models import Scrobble
def build_scrobbles(client, request_data, num=7, spacing=2):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
user = get_user_model().objects.create(username='Test User')
UserProfile.objects.create(user=user, timezone='US/Eastern')
for i in range(num):

View File

@ -10,7 +10,7 @@ from podcasts.models import Episode
@pytest.mark.django_db
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.get(url, headers=headers)
assert response.status_code == 405
@ -18,7 +18,7 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
@pytest.mark.django_db
def test_bad_mopidy_request_data(client, valid_auth_token):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(url, headers)
assert response.status_code == 400
@ -32,7 +32,7 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
def test_scrobble_mopidy_track(
client, mopidy_track_request_data, valid_auth_token
):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url,
@ -55,7 +55,7 @@ def test_scrobble_mopidy_same_track_different_album(
mopidy_track_diff_album_request_data,
valid_auth_token,
):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url,
@ -84,7 +84,7 @@ def test_scrobble_mopidy_same_track_different_album(
def test_scrobble_mopidy_podcast(
client, mopidy_podcast_request_data, valid_auth_token
):
url = reverse('scrobbles:mopidy-websocket')
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url,

View File

@ -49,6 +49,15 @@ An example of the format:
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
* DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
* TODO [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
Given a UUID from musicbrainz, we should be able to scrobble an album or
@ -58,12 +67,6 @@ individual track.
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
@ -372,9 +375,8 @@ has to re-populate when the server restarts.
}
#+end_src
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
* TODO Figure out how to add to web-scrobbler :imropvement:
* TODO [#C] Figure out how to add to web-scrobbler :imropvement:
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
* TODO When updating musicbrainz IDs, clear and run fetch artwrok :improvement:

View File

@ -0,0 +1,25 @@
from django.contrib import admin
from books.models import Author, Book
from scrobbles.admin import ScrobbleInline
@admin.register(Author)
class AlbumAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "openlibrary_id")
ordering = ("name",)
@admin.register(Book)
class ArtistAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"isbn",
"first_publish_year",
"pages",
"openlibrary_id",
)
ordering = ("title",)

View File

@ -0,0 +1,14 @@
from books.models import Author, Book
from rest_framework import serializers
class AuthorSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Author
fields = "__all__"
class BookSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Book
fields = "__all__"

View File

@ -0,0 +1,19 @@
from rest_framework import permissions, viewsets
from books.api.serializers import (
AuthorSerializer,
BookSerializer,
)
from books.models import Author, Book
class AuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.all().order_by('-created')
serializer_class = AuthorSerializer
permission_classes = [permissions.IsAuthenticated]
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all().order_by('-created')
serializer_class = BookSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -0,0 +1,128 @@
# Generated by Django 4.1.5 on 2023-02-19 20:17
from django.db import migrations, models
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Author',
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'
),
),
('name', models.CharField(max_length=255)),
(
'openlibrary_id',
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Book',
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'
),
),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
'run_time',
models.CharField(blank=True, max_length=8, null=True),
),
(
'run_time_ticks',
models.PositiveBigIntegerField(blank=True, null=True),
),
('title', models.CharField(max_length=255)),
(
'openlibrary_id',
models.CharField(blank=True, max_length=255, null=True),
),
(
'goodreads_id',
models.CharField(blank=True, max_length=255, null=True),
),
('koreader_id', models.IntegerField(blank=True, null=True)),
(
'koreader_authors',
models.CharField(blank=True, max_length=255, null=True),
),
(
'koreader_md5',
models.CharField(blank=True, max_length=255, null=True),
),
(
'isbn',
models.CharField(blank=True, max_length=255, null=True),
),
('pages', models.IntegerField(blank=True, null=True)),
(
'language',
models.CharField(blank=True, max_length=4, null=True),
),
(
'first_publish_year',
models.IntegerField(blank=True, null=True),
),
('authors', models.ManyToManyField(to='books.author')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,73 @@
import logging
from typing import Dict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
from books.utils import lookup_book_from_openlibrary
from scrobbles.utils import get_scrobbles_for_media
logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
class Author(TimeStampedModel):
name = models.CharField(max_length=255)
openlibrary_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return f"{self.name}"
def fix_metadata(self):
logger.warn("Not implemented yet")
class Book(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'BOOK_COMPLETION_PERCENT', 95)
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author)
openlibrary_id = models.CharField(max_length=255, **BNULL)
goodreads_id = models.CharField(max_length=255, **BNULL)
koreader_id = models.IntegerField(**BNULL)
koreader_authors = models.CharField(max_length=255, **BNULL)
koreader_md5 = models.CharField(max_length=255, **BNULL)
isbn = models.CharField(max_length=255, **BNULL)
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
def __str__(self):
return f"{self.title} by {self.author}"
def fix_metadata(self):
if not self.openlibrary_id:
book_meta = lookup_book_from_openlibrary(self.title, self.author)
self.openlibrary_id = book_meta.get("openlibrary_id")
self.isbn = book_meta.get("isbn")
self.goodreads_id = book_meta.get("goodreads_id")
self.first_pubilsh_year = book_meta.get("first_publish_year")
self.save()
@property
def author(self):
return self.authors.first()
def get_absolute_url(self):
return reverse("books:book_detail", kwargs={'slug': self.uuid})
@property
def pages_for_completion(self) -> int:
if not self.pages:
logger.warn(f"{self} has no pages, no completion percentage")
return 0
return int(self.pages * (self.COMPLETION_PERCENT / 100))
def progress_for_user(self, user: User) -> int:
last_scrobble = get_scrobbles_for_media(self, user).last()
return int((last_scrobble.book_pages_read / self.pages) * 100)

View File

@ -0,0 +1,47 @@
import json
from typing import Optional
import requests
import logging
logger = logging.getLogger(__name__)
SEARCH_URL = "https://openlibrary.org/search.json?title={title}"
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
search_url = SEARCH_URL.format(title=title)
response = requests.get(search_url)
if response.status_code != 200:
logger.warn(f"Bad response from OL: {response.status_code}")
return {}
results = json.loads(response.content)
if len(results.get('docs')) == 0:
logger.warn(f"No results found from OL for {title}")
return {}
top = results.get('docs')[0]
if author and author not in top['author_name']:
logger.warn(
f"Lookup for {title} found top result with mismatched author"
)
return {
"title": top.get("title"),
"isbn": top.get("isbn")[0],
"openlibrary_id": top.get("cover_edition_key"),
"author_name": get_first("author_name", top),
"author_openlibrary_id": get_first("author_key", top),
"goodreads_id": get_first("id_goodreads", top),
"first_publish_year": top.get("first_publish_year"),
}

View File

View File

@ -0,0 +1,20 @@
from music.models import Album, Artist, Track
from rest_framework import serializers
class ArtistSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Artist
fields = "__all__"
class AlbumSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Album
fields = "__all__"
class TrackSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Track
fields = "__all__"

View File

@ -0,0 +1,26 @@
from rest_framework import permissions, viewsets
from music.api.serializers import (
TrackSerializer,
ArtistSerializer,
AlbumSerializer,
)
from music.models import Artist, Album, Track
class ArtistViewSet(viewsets.ModelViewSet):
queryset = Artist.objects.all().order_by('-created')
serializer_class = ArtistSerializer
permission_classes = [permissions.IsAuthenticated]
class AlbumViewSet(viewsets.ModelViewSet):
queryset = Album.objects.all().order_by('-created')
serializer_class = AlbumSerializer
permission_classes = [permissions.IsAuthenticated]
class TrackViewSet(viewsets.ModelViewSet):
queryset = Track.objects.all().order_by('-created')
serializer_class = TrackSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -181,10 +181,18 @@ class Track(ScrobblableMixin):
def get_absolute_url(self):
return reverse('music:track_detail', kwargs={'slug': self.uuid})
@property
def subtitle(self):
return self.artist
@property
def mb_link(self):
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
@property
def info_link(self):
return self.mb_link
@classmethod
def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict

View File

@ -0,0 +1 @@
#!/usr/bin/env python3

View File

@ -1,15 +1,16 @@
import re
import logging
import re
from scrobbles.musicbrainz import (
lookup_album_dict_from_mb,
lookup_artist_from_mb,
lookup_track_from_mb,
)
logger = logging.getLogger(__name__)
from music.models import Artist, Album, Track
from music.models import Album, Artist, Track
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
@ -20,6 +21,7 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if 'featuring' in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict['id']
@ -30,9 +32,9 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
logger.debug(f"Cleaning artist {name} with {artist_dict['name']}")
# Clean up bad names in our DB with MB names
if artist.name != artist_dict['name']:
artist.name = artist_dict["name"]
artist.save(update_fields=["name"])
# if artist.name != artist_dict["name"]:
# artist.name = artist_dict["name"]
# artist.save(update_fields=["name"])
return artist
@ -92,7 +94,11 @@ def get_or_create_track(
title=title, artist=artist, album=album
).first()
# TODO Can we look up mbid for tracks?
if not mbid:
mbid = lookup_track_from_mb(
title, artist.musicbrainz_id, album.musicbrainz_id
)['id']
if not track:
track = Track.objects.create(
title=title,

View File

@ -45,6 +45,14 @@ class Episode(ScrobblableMixin):
def __str__(self):
return f"{self.title}"
@property
def subtitle(self):
return self.podcast
@property
def info_link(self):
return ""
@classmethod
def find_or_create(
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict

View File

@ -0,0 +1 @@
#!/usr/bin/env python3

View File

@ -0,0 +1,18 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
User = get_user_model()
from profiles.models import UserProfile
class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
exclude = ('password',)
class UserProfileSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = UserProfile
exclude = ('lastfm_password',)

View File

@ -0,0 +1,28 @@
from django.contrib.auth import get_user_model
from rest_framework import permissions, viewsets
from profiles.api.serializers import UserSerializer, UserProfileSerializer
from profiles.models import UserProfile
User = get_user_model()
class UserViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows users to be viewed or edited.
"""
queryset = User.objects.all().order_by('-date_joined')
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
class UserProfileViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows users to be viewed or edited.
"""
queryset = UserProfile.objects.all().order_by('-created')
serializer_class = UserProfileSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -16,7 +16,7 @@ class UserProfile(TimeStampedModel):
User, on_delete=models.CASCADE, related_name="profile"
)
timezone = models.CharField(
max_length=255, choices=PRETTY_TIMEZONE_CHOICES
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default=pytz.UTC
)
lastfm_username = models.CharField(max_length=255, **BNULL)
lastfm_password = EncryptedField(**BNULL)

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from scrobbles.models import (
AudioScrobblerTSVImport,
ChartRecord,
KoReaderImport,
LastFmImport,
Scrobble,
)
@ -14,15 +15,7 @@ class ScrobbleInline(admin.TabularInline):
exclude = ('source_id', 'scrobble_log')
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "created", "process_count", "tsv_file")
ordering = ("-created",)
@admin.register(LastFmImport)
class LastFmImportAdmin(admin.ModelAdmin):
class ImportBaseAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
@ -33,6 +26,21 @@ class LastFmImportAdmin(admin.ModelAdmin):
ordering = ("-created",)
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
""""""
@admin.register(LastFmImport)
class LastFmImportAdmin(ImportBaseAdmin):
""""""
@admin.register(KoReaderImport)
class KoReaderImportAdmin(ImportBaseAdmin):
""""""
@admin.register(ChartRecord)
class ChartRecordAdmin(admin.ModelAdmin):
date_hierarchy = "created"
@ -71,21 +79,21 @@ class ScrobbleAdmin(admin.ModelAdmin):
"is_paused",
"played_to_completion",
)
raw_id_fields = ('video', 'podcast_episode', 'track', 'sport_event')
raw_id_fields = (
'video',
'podcast_episode',
'track',
'sport_event',
'book',
)
list_filter = ("is_paused", "in_progress", "source", "track__artist")
ordering = ("-timestamp",)
def media_name(self, obj):
if obj.video:
return obj.video
if obj.track:
return obj.track
if obj.podcast_episode:
return obj.podcast_episode
if obj.sport_event:
return obj.sport_event
return obj.media_obj
def media_type(self, obj):
return obj.media_obj.__class__.__name__
if obj.video:
return "Video"
if obj.track:

View File

View File

@ -0,0 +1,33 @@
from rest_framework import serializers
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
Scrobble,
)
class ScrobbleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Scrobble
fields = "__all__"
class KoReaderImportSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = KoReaderImport
fields = "__all__"
class AudioScrobblerTSVImportSerializer(
serializers.HyperlinkedModelSerializer
):
class Meta:
model = AudioScrobblerTSVImport
fields = "__all__"
class LastFmImportSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = LastFmImport
fields = "__all__"

View File

@ -0,0 +1,49 @@
from rest_framework import permissions, viewsets
from scrobbles.api.serializers import (
AudioScrobblerTSVImportSerializer,
KoReaderImportSerializer,
LastFmImportSerializer,
ScrobbleSerializer,
)
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
Scrobble,
LastFmImport,
)
class ScrobbleViewSet(viewsets.ModelViewSet):
queryset = Scrobble.objects.all().order_by('-timestamp')
serializer_class = ScrobbleSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
class KoReaderImportViewSet(viewsets.ModelViewSet):
queryset = KoReaderImport.objects.all().order_by('-created')
serializer_class = KoReaderImportSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
class AudioScrobblerTSVImportViewSet(viewsets.ModelViewSet):
queryset = AudioScrobblerTSVImport.objects.all().order_by('-created')
serializer_class = AudioScrobblerTSVImportSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
class LastFmImportViewSet(viewsets.ModelViewSet):
queryset = LastFmImport.objects.all().order_by('-created')
serializer_class = LastFmImportSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)

View File

@ -0,0 +1,124 @@
import logging
from datetime import datetime
import sqlite3
from enum import Enum
import pytz
from books.models import Author, Book
from scrobbles.models import Scrobble
from django.utils import timezone
logger = logging.getLogger(__name__)
class KoReaderBookColumn(Enum):
ID = 0
TITLE = 1
AUTHORS = 2
NOTES = 3
LAST_OPEN = 4
HIGHLIGHTS = 5
PAGES = 6
SERIES = 7
LANGUAGE = 8
MD5 = 9
TOTAL_READ_TIME = 10
TOTAL_READ_PAGES = 11
class KoReaderPageStatColumn(Enum):
ID_BOOK = 0
PAGE = 1
START_TIME = 2
DURATION = 3
TOTAL_PAGES = 4
def process_koreader_sqlite_file(sqlite_file_path, user_id):
"""Given a sqlite file from KoReader, open the book table, iterate
over rows creating scrobbles from each book found"""
# Create a SQL connection to our SQLite database
con = sqlite3.connect(sqlite_file_path)
cur = con.cursor()
# Return all results of query
book_table = cur.execute("SELECT * FROM book")
new_scrobbles = []
for book_row in book_table:
authors = book_row[KoReaderBookColumn.AUTHORS.value].split('\n')
author_list = []
for author_str in authors:
logger.debug(f"Looking up author {author_str}")
if author_str == "N/A":
continue
author, created = Author.objects.get_or_create(name=author_str)
if created:
author.fix_metadata()
author_list.append(author)
logger.debug(f"Found author {author}, created: {created}")
book, created = Book.objects.get_or_create(
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
)
if created:
book.title = book_row[KoReaderBookColumn.TITLE.value]
book.pages = book_row[KoReaderBookColumn.PAGES.value]
book.koreader_id = int(book_row[KoReaderBookColumn.ID.value])
book.koreader_authors = book_row[KoReaderBookColumn.AUTHORS.value]
book.run_time_ticks = int(book_row[KoReaderBookColumn.PAGES.value])
book.save(
update_fields=[
"title",
"pages",
"koreader_id",
"koreader_authors",
]
)
book.fix_metadata()
if author_list:
book.authors.add(*[a.id for a in author_list])
playback_position = int(
book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
)
playback_position_ticks = playback_position * 1000
pages_read = int(book_row[KoReaderBookColumn.TOTAL_READ_PAGES.value])
timestamp = datetime.utcfromtimestamp(
book_row[KoReaderBookColumn.LAST_OPEN.value]
).replace(tzinfo=pytz.utc)
new_scrobble = Scrobble(
book_id=book.id,
user_id=user_id,
source="KOReader",
timestamp=timestamp,
playback_position_ticks=playback_position_ticks,
playback_position=playback_position,
played_to_completion=True,
in_progress=False,
book_pages_read=pages_read,
)
existing = Scrobble.objects.filter(
timestamp=timestamp, book=book
).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)
# Be sure to close the connection
con.close()
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(
f"Created {len(created)} scrobbles",
extra={'created_scrobbles': created},
)
return created

View File

@ -47,17 +47,17 @@ class LastFM:
new_scrobbles = []
source = "Last.fm"
source_id = ""
latest_scrobbles = self.get_last_scrobbles(time_from=last_processed)
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for scrobble in latest_scrobbles:
timestamp = scrobble.pop('timestamp')
for lfm_scrobble in lastfm_scrobbles:
timestamp = lfm_scrobble.pop('timestamp')
artist = get_or_create_artist(scrobble.pop('artist'))
album = get_or_create_album(scrobble.pop('album'), artist)
artist = get_or_create_artist(lfm_scrobble.pop('artist'))
album = get_or_create_album(lfm_scrobble.pop('album'), artist)
scrobble['artist'] = artist
scrobble['album'] = album
track = get_or_create_track(**scrobble)
lfm_scrobble['artist'] = artist
lfm_scrobble['album'] = album
track = get_or_create_track(**lfm_scrobble)
new_scrobble = Scrobble(
user=self.vrobbler_user,
@ -89,27 +89,6 @@ class LastFM:
)
return created
@staticmethod
def undo_lastfm_import(process_log, dryrun=True):
"""Given a newline separated list of scrobbles, delete them"""
from scrobbles.models import Scrobble
if not process_log:
logger.warning("No lines in process log found to undo")
return
for line in process_log.split('\n'):
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()
def get_last_scrobbles(self, time_from=None, time_to=None):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
@ -144,8 +123,8 @@ class LastFM:
"LastFM barfed trying to get the track for {scrobble.track}"
)
if not mbid or not artist:
logger.warn(f"Silly LastFM, bad data, bailing on {scrobble}")
if not artist:
logger.warn(f"Silly LastFM, no artist found for {scrobble}")
continue
timestamp = datetime.utcfromtimestamp(

View File

@ -0,0 +1,97 @@
# Generated by Django 4.1.5 on 2023-02-19 03:53
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import scrobbles.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
(
'scrobbles',
'0019_rename_processed_on_lastfmimport_processed_finished_and_more',
),
]
operations = [
migrations.AlterModelOptions(
name='audioscrobblertsvimport',
options={},
),
migrations.AlterModelOptions(
name='lastfmimport',
options={},
),
migrations.RenameField(
model_name='audioscrobblertsvimport',
old_name='processed_on',
new_name='processed_finished',
),
migrations.AddField(
model_name='audioscrobblertsvimport',
name='processing_started',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='KoReaderImport',
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'
),
),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
(
'processing_started',
models.DateTimeField(blank=True, null=True),
),
(
'processed_finished',
models.DateTimeField(blank=True, null=True),
),
('process_log', models.TextField(blank=True, null=True)),
('process_count', models.IntegerField(blank=True, null=True)),
(
'sqlite_file',
models.FileField(
blank=True,
null=True,
upload_to=scrobbles.models.KoReaderImport.get_path,
),
),
(
'user',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-02-19 20:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('books', '0001_initial'),
('scrobbles', '0020_alter_audioscrobblertsvimport_options_and_more'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='book',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='books.book',
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-20 00:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0021_scrobble_book'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='book_pages_read',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -7,6 +7,7 @@ from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Artist, Track
from books.models import Book
from podcasts.models import Episode
from profiles.utils import now_user_timezone
from scrobbles.lastfm import LastFM
@ -19,62 +20,7 @@ User = get_user_model()
BNULL = {"blank": True, "null": True}
class AudioScrobblerTSVImport(TimeStampedModel):
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'audioscrobbler-uploads/{uuid}.{extension}'
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
uuid = models.UUIDField(editable=False, default=uuid4)
tsv_file = models.FileField(upload_to=get_path, **BNULL)
processed_on = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
def __str__(self):
if self.tsv_file:
return f"Audioscrobbler TSV upload: {self.tsv_file.path}"
return f"Audioscrobbler TSV upload {self.id}"
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
tz = None
if self.user:
tz = self.user.profile.tzinfo
scrobbles = process_audioscrobbler_tsv_file(
self.tsv_file.path, user_tz=tz
)
self.process_log = ""
if scrobbles:
for count, scrobble in enumerate(scrobbles):
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
self.process_log += log_line
self.process_count = len(scrobbles)
else:
self.process_log = f"Created no new scrobbles"
self.process_count = 0
self.processed_on = timezone.now()
self.save(
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 LastFmImport(TimeStampedModel):
class BaseFileImportMixin(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
uuid = models.UUIDField(editable=False, default=uuid4)
processing_started = models.DateTimeField(**BNULL)
@ -82,8 +28,135 @@ class LastFmImport(TimeStampedModel):
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
class Meta:
abstract = True
def __str__(self):
return f"LastFM Import: {self.uuid}"
return f"Scrobble import {self.id}"
def process(self, force=False):
logger.warning("Process not implemented")
def undo(self, dryrun=False):
"""Accepts the log from a scrobble import and removes the scrobbles"""
from scrobbles.models import Scrobble
if not self.process_log:
logger.warning("No lines in process log found to undo")
return
for line in self.process_log.split('\n'):
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()
self.processed_finished = None
self.processing_started = None
self.process_count = None
self.process_log = ""
self.save(
update_fields=[
"processed_finished",
"processing_started",
"process_log",
"process_count",
]
)
def mark_started(self):
self.processing_started = timezone.now()
self.save(update_fields=["processing_started"])
def mark_finished(self):
self.processed_finished = timezone.now()
self.save(update_fields=['processed_finished'])
def record_log(self, scrobbles):
self.process_log = ""
if not scrobbles:
self.process_count = 0
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
self.process_log += log_line
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
class KoReaderImport(BaseFileImportMixin):
class Meta:
verbose_name = "KOReader Import"
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'koreader-uploads/{uuid}.{extension}'
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False):
from scrobbles.koreader import process_koreader_sqlite_file
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
return
self.mark_started()
scrobbles = process_koreader_sqlite_file(
self.sqlite_file.path, self.user.id
)
self.record_log(scrobbles)
self.mark_finished()
class AudioScrobblerTSVImport(BaseFileImportMixin):
class Meta:
verbose_name = "AudioScrobbler TSV Import"
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'audioscrobbler-uploads/{uuid}.{extension}'
tsv_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False):
from scrobbles.tsv import process_audioscrobbler_tsv_file
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
return
self.mark_started()
tz = None
if self.user:
tz = self.user.profile.tzinfo
scrobbles = process_audioscrobbler_tsv_file(
self.tsv_file.path, self.user.id, user_tz=tz
)
self.record_log(scrobbles)
self.mark_finished()
class LastFmImport(BaseFileImportMixin):
class Meta:
verbose_name = "Last.FM Import"
def process(self, import_all=False):
"""Import scrobbles found on LastFM"""
@ -111,36 +184,12 @@ class LastFmImport(TimeStampedModel):
if last_import:
last_processed = last_import.processed_finished
self.processing_started = timezone.now()
self.save(update_fields=['processing_started'])
self.mark_started()
scrobbles = lastfm.import_from_lastfm(last_processed)
self.process_log = ""
if scrobbles:
for count, scrobble in enumerate(scrobbles):
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
self.process_log += log_line
self.process_count = len(scrobbles)
else:
self.process_count = 0
self.processed_finished = timezone.now()
self.save(
update_fields=[
'processed_finished',
'process_count',
'process_log',
]
)
def undo(self, dryrun=False):
"""Undo import of scrobbles from LastFM"""
LastFM.undo_lastfm_import(self.process_log, dryrun)
self.processed_finished = None
self.save(update_fields=['processed_finished'])
self.record_log(scrobbles)
self.mark_finished()
class ChartRecord(TimeStampedModel):
@ -231,19 +280,29 @@ class Scrobble(TimeStampedModel):
sport_event = models.ForeignKey(
SportEvent, on_delete=models.DO_NOTHING, **BNULL
)
book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
# Time keeping
timestamp = models.DateTimeField(**BNULL)
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
playback_position = models.CharField(max_length=8, **BNULL)
# Status indicators
is_paused = models.BooleanField(default=False)
played_to_completion = models.BooleanField(default=False)
in_progress = models.BooleanField(default=True)
# Metadata
source = models.CharField(max_length=255, **BNULL)
source_id = models.TextField(**BNULL)
in_progress = models.BooleanField(default=True)
scrobble_log = models.TextField(**BNULL)
# Fields for keeping track of reads between scrobbles
book_pages_read = models.IntegerField(**BNULL)
def save(self, *args, **kwargs):
if not self.uuid:
self.uuid = uuid4()
@ -272,7 +331,10 @@ class Scrobble(TimeStampedModel):
@property
def percent_played(self) -> int:
if not self.media_obj.run_time_ticks:
if not self.media_obj:
return 0
if self.media_obj and not self.media_obj.run_time_ticks:
return 100
if not self.playback_position_ticks and self.played_to_completion:
@ -309,6 +371,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.podcast_episode
if self.sport_event:
media_obj = self.sport_event
if self.book:
media_obj = self.book
return media_obj
def __str__(self):
@ -332,6 +396,9 @@ class Scrobble(TimeStampedModel):
if media.__class__.__name__ == 'SportEvent':
media_query = models.Q(sport_event=media)
scrobble_data['sport_event_id'] = media.id
if media.__class__.__name__ == 'Book':
media_query = models.Q(book=media)
scrobble_data['book_id'] = media.id
scrobble = (
cls.objects.filter(

View File

@ -90,16 +90,18 @@ def lookup_artist_from_mb(artist_name: str) -> str:
return top_result
def lookup_track_from_mb(artist_name: str) -> str:
def lookup_track_from_mb(
track_name: str, artist_mbid: str, album_mbid: str
) -> str:
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
top_result = musicbrainzngs.search_recordings(artist=artist_name)[
'artist-list'
][0]
top_result = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mbid, release=album_mbid
)['recording-list'][0]
score = int(top_result.get('ext:score'))
if score < 85:
logger.debug(
"Artist lookup score below 85 threshold",
"Track lookup score below 85 threshold",
extra={"result": top_result},
)
return ""

View File

@ -1,14 +0,0 @@
from rest_framework import serializers
from scrobbles.models import Scrobble, AudioScrobblerTSVImport
class AudioScrobblerTSVImportSerializer(serializers.ModelSerializer):
class Meta:
model = AudioScrobblerTSVImport
fields = ('tsv_file',)
class ScrobbleSerializer(serializers.ModelSerializer):
class Meta:
model = Scrobble
fields = "__all__"

View File

@ -1,7 +1,11 @@
import logging
from celery import shared_task
from scrobbles.models import AudioScrobblerTSVImport, LastFmImport
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
)
logger = logging.getLogger(__name__)
@ -22,3 +26,12 @@ def process_tsv_import(import_id):
logger.warn(f"AudioScrobblerTSVImport not found with id {import_id}")
tsv_import.process()
@shared_task
def process_koreader_import(import_id):
koreader_import = KoReaderImport.objects.filter(id=import_id).first()
if not koreader_import:
logger.warn(f"KOReaderImport not found with id {import_id}")
koreader_import.process()

View File

@ -24,6 +24,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
)
data_dict = {
"EventId": event_id,
"ItemType": sport.default_event_type,
"Name": event.get('strEvent'),
"AltName": event.get('strEventAlternate'),

View File

@ -13,7 +13,7 @@ from music.utils import (
logger = logging.getLogger(__name__)
def process_audioscrobbler_tsv_file(file_path, user_tz=None):
def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
new_scrobbles = []
if not user_tz:
@ -49,12 +49,13 @@ def process_audioscrobbler_tsv_file(file_path, user_tz=None):
)
timestamp = (
datetime.fromtimestamp(int(row[6]))
datetime.utcfromtimestamp(int(row[6]))
.replace(tzinfo=user_tz)
.astimezone(pytz.utc)
)
new_scrobble = Scrobble(
user_id=user_id,
timestamp=timestamp,
source=source,
source_id=source_id,
@ -77,20 +78,3 @@ def process_audioscrobbler_tsv_file(file_path, user_tz=None):
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 in process_log.split('\n'):
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

@ -4,7 +4,21 @@ from scrobbles import views
app_name = 'scrobbles'
urlpatterns = [
path('', views.scrobble_endpoint, name='api-list'),
path(
'manual/imdb/',
views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble',
),
path(
'manual/audioscrobbler/',
views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path(
'manual/koreader/',
views.KoReaderImportCreateView.as_view(),
name='koreader-file-upload',
),
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
path(
@ -12,8 +26,20 @@ urlpatterns = [
views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path('lastfm-import/', views.lastfm_import, name='lastfm-import'),
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
path(
'lastfm-import/',
views.lastfm_import,
name='lastfm-import',
),
path(
'webhook/jellyfin/',
views.jellyfin_webhook,
name='jellyfin-webhook',
),
path(
'webhook/mopidy/',
views.mopidy_webhook,
name='mopidy-webhook',
),
path('export/', views.export, name='export'),
]

View File

@ -1,10 +1,14 @@
import logging
from urllib.parse import unquote
from django.contrib.auth import get_user_model
from dateutil.parser import ParserError, parse
from django.conf import settings
from django.db import models
logger = logging.getLogger(__name__)
User = get_user_model()
def convert_to_seconds(run_time: str) -> int:
@ -103,3 +107,11 @@ def check_scrobble_for_finish(
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
from scrobbles.models import Scrobble
if media_obj.__class__.__name__ == 'Book':
media_query = models.Q(book=media_obj)
return Scrobble.objects.filter(media_query, user=user)

View File

@ -4,6 +4,7 @@ from datetime import datetime
import pytz
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models.fields import timezone
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
@ -28,6 +29,7 @@ from rest_framework.decorators import (
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.api import serializers
from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
@ -35,7 +37,12 @@ from scrobbles.constants import (
from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import AudioScrobblerTSVImport, LastFmImport, Scrobble
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
Scrobble,
)
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
@ -44,11 +51,11 @@ from scrobbles.scrobblers import (
mopidy_scrobble_podcast,
mopidy_scrobble_track,
)
from scrobbles.serializers import (
AudioScrobblerTSVImportSerializer,
ScrobbleSerializer,
from scrobbles.tasks import (
process_koreader_import,
process_lastfm_import,
process_tsv_import,
)
from scrobbles.tasks import process_lastfm_import, process_tsv_import
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
logger = logging.getLogger(__name__)
@ -177,6 +184,22 @@ class AudioScrobblerImportCreateView(
return HttpResponseRedirect(self.get_success_url())
class KoReaderImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = KoReaderImport
fields = ['sqlite_file']
template_name = 'scrobbles/upload_form.html'
success_url = reverse_lazy('vrobbler-home')
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
process_koreader_import.delay(self.object.id)
return HttpResponseRedirect(self.get_success_url())
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def lastfm_import(request):
@ -190,19 +213,10 @@ def lastfm_import(request):
return HttpResponseRedirect(success_url)
@csrf_exempt
@api_view(['GET'])
def scrobble_endpoint(request):
"""List all Scrobbles, or create a new Scrobble"""
scrobble = Scrobble.objects.all()
serializer = ScrobbleSerializer(scrobble, many=True)
return Response(serializer.data)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def jellyfin_websocket(request):
def jellyfin_webhook(request):
data_dict = request.data
if (
@ -234,7 +248,7 @@ def jellyfin_websocket(request):
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def mopidy_websocket(request):
def mopidy_webhook(request):
try:
data_dict = json.loads(request.data)
except TypeError:
@ -268,7 +282,9 @@ def import_audioscrobbler_file(request):
scrobbles_created = []
# tsv_file = request.FILES[0]
file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
file_serializer = serializers.AudioScrobblerTSVImportSerializer(
data=request.data
)
if file_serializer.is_valid():
import_file = file_serializer.save()
return Response(
@ -280,39 +296,48 @@ def import_audioscrobbler_file(request):
)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def scrobble_finish(request, uuid):
user = request.user
success_url = reverse_lazy('vrobbler-home')
if not user.is_authenticated:
return Response({}, status=status.HTTP_403_FORBIDDEN)
return HttpResponseRedirect(success_url)
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
if not scrobble:
return Response({}, status=status.HTTP_404_NOT_FOUND)
scrobble.stop(force_finish=True)
return Response(
{'id': scrobble.id, 'status': scrobble.status},
status=status.HTTP_200_OK,
)
if scrobble:
scrobble.stop(force_finish=True)
messages.add_message(
request,
messages.SUCCESS,
f"Scrobble of {scrobble.media_obj} finished.",
)
else:
messages.add_message(request, messages.ERROR, "Scrobble not found.")
return HttpResponseRedirect(success_url)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def scrobble_cancel(request, uuid):
user = request.user
success_url = reverse_lazy('vrobbler-home')
if not user.is_authenticated:
return Response({}, status=status.HTTP_403_FORBIDDEN)
return HttpResponseRedirect(success_url)
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
if not scrobble:
return Response({}, status=status.HTTP_404_NOT_FOUND)
scrobble.cancel()
return Response(
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
)
if scrobble:
scrobble.cancel()
messages.add_message(
request,
messages.SUCCESS,
f"Scrobble of {scrobble.media_obj} cancelled.",
)
else:
messages.add_message(request, messages.ERROR, "Scrobble not found.")
return HttpResponseRedirect(success_url)
@permission_classes([IsAuthenticated])

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-23 15:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sports', '0007_sport_default_event_type'),
]
operations = [
migrations.AddField(
model_name='sportevent',
name='thesportsdb_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -15,8 +15,9 @@ BNULL = {"blank": True, "null": True}
class SportEventType(models.TextChoices):
UNKNOWN = 'UK', _('Unknown')
UNKNOWN = 'UK', _('Event')
GAME = 'GA', _('Game')
RACE = 'RA', _('Race')
MATCH = 'MA', _('Match')
@ -89,6 +90,7 @@ class Round(TheSportsDbMixin):
class SportEvent(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
thesportsdb_id = models.CharField(max_length=255, **BNULL)
event_type = models.CharField(
max_length=2,
choices=SportEventType.choices,
@ -127,6 +129,18 @@ class SportEvent(ScrobblableMixin):
def get_absolute_url(self):
return reverse("sports:event_detail", kwargs={'slug': self.uuid})
@property
def subtitle(self):
return self.round.season.league
@property
def sportsdb_link(self):
return f"https://thesportsdb.com/event/{self.thesportsdb_id}"
@property
def info_link(self):
return self.sportsdb_link
@classmethod
def find_or_create(cls, data_dict: Dict) -> "Event":
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
@ -208,6 +222,7 @@ class SportEvent(ScrobblableMixin):
away_team, _created = Team.objects.get_or_create(**away_team_dict)
event_dict = {
"thesportsdb_id": data_dict.get("EventId"),
"title": data_dict.get("Name"),
"event_type": sport.default_event_type,
"home_team": home_team,

View File

View File

@ -0,0 +1,14 @@
from videos.models import Series, Video
from rest_framework import serializers
class SeriesSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Series
fields = "__all__"
class VideoSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Video
fields = "__all__"

View File

@ -0,0 +1,19 @@
from rest_framework import permissions, viewsets
from videos.api.serializers import (
SeriesSerializer,
VideoSerializer,
)
from videos.models import Series, Video
class SeriesViewSet(viewsets.ModelViewSet):
queryset = Series.objects.all().order_by('-created')
serializer_class = SeriesSerializer
permission_classes = [permissions.IsAuthenticated]
class VideoViewSet(viewsets.ModelViewSet):
queryset = Video.objects.all().order_by('-created')
serializer_class = VideoSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -69,10 +69,24 @@ class Video(ScrobblableMixin):
def get_absolute_url(self):
return reverse("videos:video_detail", kwargs={'slug': self.uuid})
@property
def subtitle(self):
if self.tv_series:
return self.tv_series
return ""
@property
def imdb_link(self):
return f"https://www.imdb.com/title/{self.imdb_id}"
@property
def info_link(self):
return self.imdb_link
@property
def link(self):
return self.imdb_link
@classmethod
def find_or_create(cls, data_dict: Dict) -> "Video":
"""Given a data dict from Jellyfin, does the heavy lifting of looking up

View File

@ -97,6 +97,7 @@ INSTALLED_APPS = [
"music",
"podcasts",
"sports",
"books",
"mathfilters",
"rest_framework",
"allauth",
@ -139,6 +140,8 @@ TEMPLATES = [
},
]
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
WSGI_APPLICATION = "vrobbler.wsgi.application"
DATABASES = {
@ -170,22 +173,19 @@ AUTHENTICATION_BACKENDS = [
"allauth.account.auth_backends.AuthenticationBackend",
]
# We have to ignore content negotiation because Jellyfin is a bad actor
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend"
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
],
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
"PAGE_SIZE": 100,
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 200,
}
LOGIN_REDIRECT_URL = "/"

View File

@ -177,7 +177,7 @@
</button>
{% if user.is_authenticated %}
<form id="scrobble-form" action="{% url 'imdb-manual-scrobble' %}" method="post">
<form id="scrobble-form" action="{% url 'scrobbles:imdb-manual-scrobble' %}" method="post">
{% csrf_token %}
{{ imdb_form }}
</form>
@ -197,16 +197,20 @@
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3">
{% if messages %}
<ul style="padding-right:10px;">
{% for message in messages %}
<li {% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{{scrobble.media_obj.title}}<br/>
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
{% if scrobble.sport_event %}<em>{{scrobble.sport_event.round.season.league}}</em><br/>{% endif %}
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
@ -219,8 +223,6 @@
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">
@ -271,6 +273,7 @@
{% endblock %}
</div>
</div>
<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 */

View File

@ -3,7 +3,7 @@
{% block content %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
<main class="col-md-9 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">Dashboard</h1>
@ -316,7 +316,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<form action="{% url 'audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
<form action="{% url 'scrobbles:audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
<div class="modal-body">
{% csrf_token %}
<div class="form-group">
@ -326,7 +326,18 @@
</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>
<form action="{% url 'scrobbles:koreader-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">KOReader sqlite file:</label>
<input type="file" name="sqlite_file" class="form-control" id="id_sqlite_file">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>

View File

@ -1,13 +1,14 @@
{% 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">
<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">
<form action="{% url 'scrobbles:audioscrobbler-file-upload' %}" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="Submit">
</form>
</div>
</main>
{% endblock %}
{% endblock %}

View File

@ -3,29 +3,49 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from scrobbles import urls as scrobble_urls
from music import urls as music_urls
from videos import urls as video_urls
from rest_framework import routers
from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet
from vrobbler.apps.music import urls as music_urls
from vrobbler.apps.music.api.views import (
AlbumViewSet,
ArtistViewSet,
TrackViewSet,
)
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet
from vrobbler.apps.scrobbles import urls as scrobble_urls
from vrobbler.apps.scrobbles.api.views import (
AudioScrobblerTSVImportViewSet,
KoReaderImportViewSet,
LastFmImportViewSet,
ScrobbleViewSet,
)
from vrobbler.apps.videos import urls as video_urls
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
router = routers.DefaultRouter()
router.register(r'scrobbles', ScrobbleViewSet)
router.register(r'lastfm-imports', LastFmImportViewSet)
router.register(r'tsv-imports', AudioScrobblerTSVImportViewSet)
router.register(r'koreader-imports', KoReaderImportViewSet)
router.register(r'artist', ArtistViewSet)
router.register(r'album', AlbumViewSet)
router.register(r'tracks', TrackViewSet)
router.register(r'series', SeriesViewSet)
router.register(r'videos', VideoViewSet)
router.register(r'authors', AuthorViewSet)
router.register(r'books', BookViewSet)
router.register(r'users', UserViewSet)
router.register(r'user_profiles', UserProfileViewSet)
urlpatterns = [
path('api/v1/', include(router.urls)),
path('api/v1/auth', include("rest_framework.urls")),
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
# path("api-auth/", include("rest_framework.urls")),
# path("movies/", include(movies, namespace="movies")),
# path("shows/", include(shows, namespace="shows")),
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
path(
'manual/imdb/',
scrobbles_views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble',
),
path(
'manual/audioscrobbler/',
scrobbles_views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path("", include(music_urls, namespace="music")),
path("", include(video_urls, namespace="videos")),
path("", include(scrobble_urls, namespace="scrobbles")),
path(
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"
),