Compare commits

..

45 Commits

Author SHA1 Message Date
fd6e0f49b6 Bump version to 0.12.0
Version 1.0 approaches!
2023-03-02 15:10:58 -05:00
93299a1abd Hide album urls that don't exist 2023-03-02 15:08:34 -05:00
a58ddebd23 Fix typo in audiodb lookup 2023-03-02 15:08:22 -05:00
41cdb96e94 Clean up album admin with new data 2023-03-02 15:08:11 -05:00
5a8e828b81 Add rudimentary album metadata from TADB 2023-03-02 14:48:33 -05:00
c84a3072be Dont show None if bio is missing 2023-03-02 11:26:31 -05:00
0bd7ed4463 Clean up music detail views 2023-03-02 11:03:26 -05:00
ee232aa103 Fix Le Static Files 2023-03-01 19:11:16 -05:00
7151646600 Bump version to 0.11.4 2023-03-01 00:15:29 -05:00
1d7cf965ef Clean up album and artist views 2023-03-01 00:13:31 -05:00
0a9279dbd4 Super hack for chart pages 2023-02-28 22:11:37 -05:00
bf3479dbc7 Skip IMDB tests that aren't used 2023-02-28 21:33:46 -05:00
a99dca246b Fix chart display 2023-02-28 01:58:22 -05:00
f76aaf6a9c Condense live charts to one function 2023-02-28 01:57:46 -05:00
ce1541bb2d Add sports API 2023-02-27 13:07:18 -05:00
d34e56aa89 Bump version to 0.11.3 2023-02-27 11:13:45 -05:00
6316d4bead Fix chart templates 2023-02-27 11:13:13 -05:00
56e5728245 Bump version to 0.11.2 2023-02-27 10:30:44 -05:00
6ff170e169 Quite a few bugs 2023-02-27 10:30:20 -05:00
86d1cf0d65 Bump version to 0.11.1 2023-02-26 23:27:17 -05:00
a0101bf1ae Add first pass at AudioDB fetching 2023-02-26 23:26:51 -05:00
457afdc9ef Big fix to aggregation 2023-02-26 22:21:49 -05:00
d5bf6440b0 Bump version to 0.11.0 2023-02-26 02:00:37 -05:00
803ed7d8d7 Add base chart view 2023-02-26 02:00:15 -05:00
93c4dd3d3b Fix aggregation 2023-02-26 01:56:11 -05:00
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
d944fdd0c0 Bump version to 0.9.3 2023-02-16 23:58:27 -05:00
e345631e27 Spin TSV imports off to tasks 2023-02-16 23:58:06 -05:00
91 changed files with 3155 additions and 816 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.2"
version = "0.11.5"
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

@ -5,18 +5,13 @@ import time_machine
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from profiles.models import UserProfile
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):
@ -55,7 +50,7 @@ def test_week_of_scrobbles_data(client, mopidy_track_request_data):
def test_top_tracks_by_day(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_tracks(user)
tops = live_charts(user)
assert tops[0].title == "Same in the End"
@ -63,7 +58,7 @@ def test_top_tracks_by_day(client, mopidy_track_request_data):
def test_top_tracks_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_tracks(user, filter='week')
tops = live_charts(user, chart_period='week')
assert tops[0].title == "Same in the End"
@ -71,7 +66,7 @@ def test_top_tracks_by_week(client, mopidy_track_request_data):
def test_top_tracks_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_tracks(user, filter='month')
tops = live_charts(user, chart_period='month')
assert tops[0].title == "Same in the End"
@ -79,7 +74,7 @@ def test_top_tracks_by_month(client, mopidy_track_request_data):
def test_top_tracks_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_tracks(user, filter='year')
tops = live_charts(user, chart_period='year')
assert tops[0].title == "Same in the End"
@ -87,7 +82,7 @@ def test_top_tracks_by_year(client, mopidy_track_request_data):
def test_top__artists_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_artists(user, filter='week')
tops = live_charts(user, chart_period='week', media_type="Artist")
assert tops[0].name == "Sublime"
@ -95,7 +90,7 @@ def test_top__artists_by_week(client, mopidy_track_request_data):
def test_top__artists_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_artists(user, filter='month')
tops = live_charts(user, chart_period='month', media_type="Artist")
assert tops[0].name == "Sublime"
@ -103,5 +98,5 @@ def test_top__artists_by_month(client, mopidy_track_request_data):
def test_top__artists_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = top_artists(user, filter='year')
tops = live_charts(user, chart_period='year', media_type="Artist")
assert tops[0].name == "Sublime"

View File

@ -1,10 +1,9 @@
import pytest
import imdb
from mock import patch
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
@pytest.mark.skip(reason="Need to sort out third party API testing")
def test_lookup_imdb_bad_id(caplog):
data = lookup_video_from_imdb('3409324')
assert data is None

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

@ -8,8 +8,18 @@ from scrobbles.admin import ScrobbleInline
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "year", "musicbrainz_id")
list_filter = ("year",)
list_display = (
"name",
"year",
"primary_artist",
"theaudiodb_genre",
"theaudiodb_mood",
"musicbrainz_id",
)
list_filter = (
"theaudiodb_score",
"theaudiodb_genre",
)
ordering = ("name",)
filter_horizontal = [
'artists',

View File

@ -1,22 +1,14 @@
from datetime import datetime, timedelta
from typing import List, Optional
from typing import List
import pytz
from django.db.models import Count, Q, Sum
from django.apps import apps
from django.db.models import Count, Q, QuerySet
from django.utils import timezone
from music.models import Artist, Track
from scrobbles.models import Scrobble
from videos.models import Video
from vrobbler.apps.profiles.utils import now_user_timezone
NOW = timezone.now()
START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(
days=NOW.today().isoweekday() % 7
)
STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
def scrobble_counts(user=None):
@ -55,7 +47,9 @@ def scrobble_counts(user=None):
return data
def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
def week_of_scrobbles(
user=None, start=None, media: str = 'tracks'
) -> dict[str, int]:
now = timezone.now()
user_filter = Q()
@ -63,9 +57,8 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
now = now_user_timezone(user.profile)
user_filter = Q(user=user)
start_of_today = datetime.combine(
now.date(), datetime.min.time(), now.tzinfo
)
if not start:
start = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
scrobble_day_dict = {}
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
@ -77,13 +70,13 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
for day in range(6, -1, -1):
start = start_of_today - timedelta(days=day)
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
day_of_week = start.strftime('%A')
start_day = start - timedelta(days=day)
end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
day_of_week = start_day.strftime('%A')
scrobble_day_dict[day_of_week] = base_qs.filter(
media_filter,
timestamp__gte=start,
timestamp__gte=start_day,
timestamp__lte=end,
played_to_completion=True,
).count()
@ -91,58 +84,64 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
return scrobble_day_dict
def top_tracks(
user: "User", filter: str = "today", limit: int = 15
) -> List["Track"]:
def live_charts(
user: "User",
media_type: str = "Track",
chart_period: str = "all",
limit: int = 15,
) -> QuerySet:
now = timezone.now()
tzinfo = now.tzinfo
now = now.date()
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
start_of_today = datetime.combine(
now.date(), datetime.min.time(), now.tzinfo
)
starting_day_of_current_week = now.date() - timedelta(
days=now.today().isoweekday() % 7
)
starting_day_of_current_month = now.date().replace(day=1)
starting_day_of_current_year = now.date().replace(month=1, day=1)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = now - timedelta(days=now.today().isoweekday() % 7)
start_day_of_month = now.replace(day=1)
start_day_of_year = now.replace(month=1, day=1)
time_filter = Q(scrobble__timestamp__gte=start_of_today)
if filter == "week":
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
if filter == "month":
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_month)
if filter == "year":
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_year)
media_model = apps.get_model(app_label='music', model_name=media_type)
period_queries = {
'today': {'scrobble__timestamp__gte': start_of_today},
'week': {'scrobble__timestamp__gte': start_day_of_week},
'month': {'scrobble__timestamp__gte': start_day_of_month},
'year': {'scrobble__timestamp__gte': start_day_of_year},
'all': {},
}
time_filter = Q()
completion_filter = Q(
scrobble__user=user, scrobble__played_to_completion=True
)
user_filter = Q(scrobble__user=user)
count_field = "scrobble"
if media_type == "Artist":
for period, query_dict in period_queries.items():
period_queries[period] = {
"track__" + k: v for k, v in query_dict.items()
}
completion_filter = Q(
track__scrobble__user=user,
track__scrobble__played_to_completion=True,
)
count_field = "track__scrobble"
user_filter = Q(track__scrobble__user=user)
time_filter = Q(**period_queries[chart_period])
return (
Track.objects.filter(time_filter)
.annotate(num_scrobbles=Count("scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit]
)
def top_artists(
user: "User", filter: str = "today", limit: int = 15
) -> List["Artist"]:
time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK
media_model.objects.filter(user_filter, time_filter)
.annotate(
num_scrobbles=Count(
count_field,
filter=completion_filter,
distinct=True,
)
)
if filter == "month":
time_filter = Q(
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH
)
if filter == "year":
time_filter = Q(
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR
)
return (
Artist.objects.filter(time_filter)
.annotate(num_scrobbles=Count("track__scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit]
)

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

@ -0,0 +1,28 @@
# Generated by Django 4.1.5 on 2023-02-27 03:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0009_alter_track_musicbrainz_id_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='biography',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_genre',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_mood',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.5 on 2023-02-27 04:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0010_artist_biography_artist_theaudiodb_genre_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='thumbnail',
field=models.ImageField(
blank=True, null=True, upload_to='artist/'
),
),
]

View File

@ -0,0 +1,85 @@
# Generated by Django 4.1.5 on 2023-03-02 19:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0011_artist_thumbnail'),
]
operations = [
migrations.AddField(
model_name='album',
name='allmusic_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='discogs_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='rateyourmusic_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_description',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_genre',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_id',
field=models.CharField(
blank=True, max_length=255, null=True, unique=True
),
),
migrations.AddField(
model_name='album',
name='theaudiodb_mood',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_score',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_score_votes',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_speed',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_style',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='theaudiodb_theme',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='wikidata_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='album',
name='wikipedia_slug',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-03-02 19:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0012_album_allmusic_id_album_discogs_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='album',
name='theaudiodb_score',
field=models.FloatField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-03-02 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0013_alter_album_theaudiodb_score'),
]
operations = [
migrations.AddField(
model_name='album',
name='theaudiodb_year_released',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -1,15 +1,19 @@
import logging
from tempfile import NamedTemporaryFile
from typing import Dict, Optional
from urllib.request import urlopen
from uuid import uuid4
import musicbrainzngs
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.base import ContentFile, File
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
from scrobbles.theaudiodb import lookup_artist_from_tadb
from vrobbler.apps.scrobbles.theaudiodb import lookup_album_from_tadb
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -18,7 +22,11 @@ BNULL = {"blank": True, "null": True}
class Artist(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
biography = models.TextField(**BNULL)
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
class Meta:
unique_together = [['name', 'musicbrainz_id']]
@ -53,6 +61,22 @@ class Artist(TimeStampedModel):
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
def fix_metadata(self):
tadb_info = lookup_artist_from_tadb(self.name)
if not tadb_info:
logger.warn(f"No response from TADB for artist {self.name}")
return
self.biography = tadb_info['biography']
self.theaudiodb_genre = tadb_info['genre']
self.theaudiodb_mood = tadb_info['mood']
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(urlopen(tadb_info['thumb_url']).read())
img_temp.flush()
img_filename = f"{self.name}_{self.uuid}.jpg"
self.thumbnail.save(img_filename, File(img_temp))
class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -63,14 +87,56 @@ class Album(TimeStampedModel):
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
cover_image = models.ImageField(upload_to="albums/", **BNULL)
theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
theaudiodb_description = models.TextField(**BNULL)
theaudiodb_year_released = models.IntegerField(**BNULL)
theaudiodb_score = models.FloatField(**BNULL)
theaudiodb_score_votes = models.IntegerField(**BNULL)
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_style = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
theaudiodb_speed = models.CharField(max_length=255, **BNULL)
theaudiodb_theme = models.CharField(max_length=255, **BNULL)
allmusic_id = models.CharField(max_length=255, **BNULL)
rateyourmusic_id = models.CharField(max_length=255, **BNULL)
wikipedia_slug = models.CharField(max_length=255, **BNULL)
discogs_id = models.CharField(max_length=255, **BNULL)
wikidata_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("music:album_detail", kwargs={'slug': self.uuid})
def scrobbles(self):
from scrobbles.models import Scrobble
return Scrobble.objects.filter(
track__in=self.track_set.all()
).order_by('-timestamp')
@property
def tracks(self):
return (
self.track_set.all()
.annotate(scrobble_count=models.Count('scrobble'))
.order_by('-scrobble_count')
)
@property
def primary_artist(self):
return self.artists.first()
def scrape_theaudiodb(self) -> None:
artist = self.primary_artist.name
album_data = lookup_album_from_tadb(self.name, artist)
if not album_data.get('theaudiodb_id'):
logger.info(f"No data for {self} found in TheAudioDB")
return
Album.objects.filter(pk=self.pk).update(**album_data)
def fix_metadata(self):
if (
not self.musicbrainz_albumartist_id
@ -118,6 +184,7 @@ class Album(TimeStampedModel):
or self.cover_image == 'default-image-replace-me'
):
self.fetch_artwork()
self.scrape_theaudiodb()
def fetch_artwork(self, force=False):
if not self.cover_image and not force:
@ -156,9 +223,27 @@ class Album(TimeStampedModel):
self.save()
@property
def mb_link(self):
def mb_link(self) -> str:
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
@property
def allmusic_link(self) -> str:
if self.allmusic_id:
return f"https://www.allmusic.com/artist/{self.allmusic_id}"
return ""
@property
def wikipedia_link(self):
if self.wikipedia_slug:
return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
return ""
@property
def tadb_link(self):
if self.theaudiodb_id:
return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
return ""
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
@ -181,10 +266,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

@ -6,6 +6,11 @@ app_name = 'music'
urlpatterns = [
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
path(
'album/<slug:slug>/',
views.AlbumDetailView.as_view(),
name='album_detail',
),
path("tracks/", views.TrackListView.as_view(), name='tracks_list'),
path(
'tracks/<slug:slug>/',

View File

@ -1,15 +1,18 @@
import re
import logging
import re
from musicbrainzngs.caa import musicbrainz
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,19 +23,20 @@ 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()
if '&' in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict['id']
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
artist, created = Artist.objects.get_or_create(
name=name, musicbrainz_id=mbid
)
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"])
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
logger.debug(
f"Created artist {artist.name} ({artist.musicbrainz_id}) "
)
artist.fix_metadata()
return artist
@ -76,23 +80,22 @@ def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
def get_or_create_track(
title: str,
mbid: str,
artist: Artist,
album: Album,
mbid: str = None,
run_time=None,
run_time_ticks=None,
) -> Track:
track = None
if mbid:
track = Track.objects.filter(
musicbrainz_id=mbid,
).first()
if not track:
track = Track.objects.filter(
title=title, artist=artist, album=album
).first()
if not mbid:
mbid = lookup_track_from_mb(
title,
artist.musicbrainz_id,
album.musicbrainz_id,
)['id']
track = Track.objects.filter(musicbrainz_id=mbid).first()
# TODO Can we look up mbid for tracks?
if not track:
track = Track.objects.create(
title=title,

View File

@ -1,10 +1,14 @@
from datetime import timedelta
from django.utils import timezone
from django.views import generic
from music.models import Track, Artist, Album
from music.models import Album, Artist, Track
from scrobbles.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
class TrackListView(generic.ListView):
model = Track
paginate_by = 200
def get_queryset(self):
return get_scrobble_count_qs(user=self.request.user).order_by(
@ -16,18 +20,64 @@ class TrackDetailView(generic.DetailView):
model = Track
slug_field = 'uuid'
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data['charts'] = ChartRecord.objects.filter(
track=self.object, rank__in=[1, 2, 3]
)
return context_data
class ArtistListView(generic.ListView):
model = Artist
paginate_by = 100
def get_queryset(self):
return super().get_queryset().order_by("name")
def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data(
object_list=object_list, **kwargs
)
context_data['view'] = self.request.GET.get('view')
return context_data
class ArtistDetailView(generic.DetailView):
model = Artist
slug_field = 'uuid'
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data['charts'] = ChartRecord.objects.filter(
artist=self.object, rank__in=[1, 2, 3]
)
return context_data
class AlbumListView(generic.ListView):
model = Album
paginate_by = 50
def get_queryset(self):
return super().get_queryset().order_by("name")
def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data(
object_list=object_list, **kwargs
)
context_data['view'] = self.request.GET.get('view')
return context_data
class AlbumDetailView(generic.DetailView):
model = Album
slug_field = 'uuid'
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
# context_data['charts'] = ChartRecord.objects.filter(
# track__album=self.object, rank__in=[1, 2, 3]
# )
return context_data

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,12 +26,28 @@ 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"
list_display = (
"user",
"rank",
"count",
"year",
"week",
"month",
@ -71,21 +80,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,17 @@
import pytz
from django.utils import timezone
from scrobbles.models import Scrobble
def now_playing(request):
user = request.user
now = timezone.now()
if not user.is_authenticated:
return {}
return {
'now_playing_list': Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=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

@ -0,0 +1,30 @@
# Generated by Django 4.1.5 on 2023-02-25 00:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0022_scrobble_book_pages_read'),
]
operations = [
migrations.AlterModelOptions(
name='audioscrobblertsvimport',
options={'verbose_name': 'AudioScrobbler TSV Import'},
),
migrations.AlterModelOptions(
name='koreaderimport',
options={'verbose_name': 'KOReader Import'},
),
migrations.AlterModelOptions(
name='lastfmimport',
options={'verbose_name': 'Last.FM Import'},
),
migrations.AddField(
model_name='chartrecord',
name='count',
field=models.IntegerField(default=0),
),
]

View File

@ -2,85 +2,27 @@ import calendar
import logging
from uuid import uuid4
from books.models import Book
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Artist, Track
from podcasts.models import Episode
from profiles.utils import now_user_timezone
from scrobbles.lastfm import LastFM
from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent
from videos.models import Series, Video
from vrobbler.apps.scrobbles.stats import build_charts
logger = logging.getLogger(__name__)
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 save(self, **kwargs):
"""On save, attempt to import the TSV file"""
super().save(**kwargs)
self.process()
return
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)
@ -88,8 +30,177 @@ 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}"
@property
def human_start(self):
start = "Unknown"
if self.processing_started:
start = self.processing_started.strftime('%B %d, %Y at %H:%M')
return start
@property
def import_type(self) -> str:
class_name = self.__class__.__name__
if class_name == 'AudioscrobblerTSVImport':
return "Audioscrobbler"
if class_name == 'KoReaderImport':
return "KoReader"
if self.__class__.__name__ == 'LastFMImport':
return "LastFM"
return "Generic"
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 __str__(self):
return f"KoReader import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
)
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 __str__(self):
return f"Audioscrobbler import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
)
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 __str__(self):
return f"LastFM import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
)
def process(self, import_all=False):
"""Import scrobbles found on LastFM"""
@ -117,36 +228,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):
@ -160,6 +247,7 @@ class ChartRecord(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
rank = models.IntegerField()
count = models.IntegerField(default=0)
year = models.IntegerField(default=timezone.now().year)
month = models.IntegerField(**BNULL)
week = models.IntegerField(**BNULL)
@ -176,6 +264,8 @@ class ChartRecord(TimeStampedModel):
media_obj = self.video
if self.track:
media_obj = self.track
if self.artist:
media_obj = self.artist
return media_obj
@property
@ -222,7 +312,42 @@ class ChartRecord(TimeStampedModel):
return period
def __str__(self):
return f"#{self.rank} in {self.period} - {self.media_obj}"
title = f"#{self.rank} in {self.period}"
if self.day or self.week:
title = f"#{self.rank} on {self.period}"
return title
def link(self):
get_params = f"?date={self.year}"
if self.week:
get_params = get_params = get_params + f"-W{self.week}"
if self.month:
get_params = get_params = get_params + f"-{self.month}"
if self.day:
get_params = get_params = get_params + f"-{self.day}"
if self.artist:
get_params = get_params + "&media=Artist"
return reverse('scrobbles:charts-home') + get_params
@classmethod
def build(cls, user, **kwargs):
build_charts(user=user, **kwargs)
@classmethod
def for_year(cls, user, year):
return cls.objects.filter(year=year, user=user)
@classmethod
def for_month(cls, user, year, month):
return cls.objects.filter(year=year, month=month, user=user)
@classmethod
def for_day(cls, user, year, day, month):
return cls.objects.filter(year=year, month=month, day=day, user=user)
@classmethod
def for_week(cls, user, year, week):
return cls.objects.filter(year=year, week=week, user=user)
class Scrobble(TimeStampedModel):
@ -237,19 +362,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()
@ -278,7 +413,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:
@ -315,6 +453,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):
@ -338,6 +478,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

@ -89,10 +89,6 @@ def mopidy_scrobble_track(
"mopidy_status": data_dict.get("status"),
}
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
return scrobble
@ -130,7 +126,7 @@ def jellyfin_scrobble_track(
)
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress:
if null_position_on_progress:
logger.error("No playback position tick from Jellyfin, aborting")
return
@ -152,7 +148,6 @@ def jellyfin_scrobble_track(
)
track = get_or_create_track(
title=data_dict.get("Name"),
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
artist=artist,
album=album,
run_time_ticks=run_time_ticks,

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__"

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -6,7 +6,7 @@ from typing import Optional
import pytz
from django.apps import apps
from django.conf import settings
from django.db.models import Count, Q
from django.db.models import Count, Q, ExpressionWrapper, OuterRef, Subquery
logger = logging.getLogger(__name__)
@ -30,12 +30,14 @@ def get_scrobble_count_qs(
user=None,
model_str="Track",
) -> dict[str, int]:
tz = settings.TIME_ZONE
if user and user.is_authenticated:
tz = pytz.timezone(user.profile.timezone)
tz = pytz.utc
data_model = apps.get_model(app_label='music', model_name='Track')
if model_str == "Artist":
data_model = apps.get_model(app_label='music', model_name='Artist')
if model_str == "Video":
data_model = apps.get_model(app_label='videos', model_name='Video')
if model_str == "SportEvent":
@ -43,10 +45,16 @@ def get_scrobble_count_qs(
app_label='sports', model_name='SportEvent'
)
base_qs = data_model.objects.filter(
scrobble__user=user,
scrobble__played_to_completion=True,
)
if model_str == "Artist":
base_qs = data_model.objects.filter(
track__scrobble__user=user,
track__scrobble__played_to_completion=True,
)
else:
base_qs = data_model.objects.filter(
scrobble__user=user,
scrobble__played_to_completion=True,
)
# Returna all media items with scrobble count annotated
if not year:
@ -56,29 +64,41 @@ def get_scrobble_count_qs(
start = datetime(year, 1, 1, tzinfo=tz)
end = datetime(year, 12, 31, tzinfo=tz)
if month:
if year and day and month:
logger.debug('Filtering by year, month and day')
start = datetime(year, month, day, 0, 0, tzinfo=tz)
end = datetime(year, month, day, 23, 59, tzinfo=tz)
elif year and week:
logger.debug('Filtering by year and week')
start, end = get_start_end_dates_by_week(year, week, tz)
elif month:
logger.debug('Filtering by month')
end_day = calendar.monthrange(year, month)[1]
start = datetime(year, month, 1, tzinfo=tz)
end = datetime(year, month, end_day, tzinfo=tz)
elif week:
start, end = get_start_end_dates_by_week(year, week, tz)
elif day and month:
start = datetime(year, month, day, 0, 0, tzinfo=tz)
end = datetime(year, month, day, 23, 59, tzinfo=tz)
elif day and not month:
logger.warning('Day provided with month, defaulting ot all-time')
date_filter = Q(
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
)
return (
base_qs.annotate(
scrobble_count=Count("scrobble", filter=Q(date_filter))
if model_str == "Artist":
scrobble_date_filter = Q(
track__scrobble__timestamp__gte=start,
track__scrobble__timestamp__lte=end,
)
.filter(date_filter)
.order_by("-scrobble_count")
)
qs = (
base_qs.filter(scrobble_date_filter)
.annotate(scrobble_count=Count("track__scrobble", distinct=True))
.order_by("-scrobble_count")
)
else:
scrobble_date_filter = Q(
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
)
qs = (
base_qs.filter(scrobble_date_filter)
.annotate(scrobble_count=Count("scrobble", distinct=True))
.order_by("-scrobble_count")
)
return qs
def build_charts(
@ -109,7 +129,14 @@ def build_charts(
'user': user,
}
chart_record['rank'] = ranks[result.scrobble_count]
chart_record['count'] = result.scrobble_count
if model_str == 'Track':
chart_record['track'] = result
if model_str == 'Video':
chart_record['video'] = result
if model_str == 'Artist':
chart_record['artist'] = result
chart_records.append(ChartRecord(**chart_record))
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True)
ChartRecord.objects.bulk_create(
chart_records, ignore_conflicts=True, batch_size=500
)

View File

@ -1,7 +1,11 @@
import logging
from celery import shared_task
from scrobbles.models import LastFmImport
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
)
logger = logging.getLogger(__name__)
@ -13,3 +17,21 @@ def process_lastfm_import(import_id):
logger.warn(f"LastFmImport not found with id {import_id}")
lastfm_import.process()
@shared_task
def process_tsv_import(import_id):
tsv_import = AudioScrobblerTSVImport.objects.filter(id=import_id).first()
if not tsv_import:
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

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def urlreplace(context, **kwargs):
query = context['request'].GET.copy()
query.update(kwargs)
return query.urlencode()

View File

@ -0,0 +1,75 @@
import json
import logging
import requests
from django.conf import settings
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
ARTIST_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
ALBUM_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/searchalbum.php?s="
logger = logging.getLogger(__name__)
def lookup_artist_from_tadb(name: str) -> dict:
artist_info = {}
response = requests.get(ARTIST_SEARCH_URL + name)
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return {}
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return {}
results = json.loads(response.content)
if results['artists']:
artist = results['artists'][0]
artist_info['biography'] = artist.get('strBiographyEN')
artist_info['genre'] = artist.get('strGenre')
artist_info['mood'] = artist.get('strMood')
artist_info['thumb_url'] = artist.get('strArtistThumb')
return artist_info
def lookup_album_from_tadb(name: str, artist: str) -> dict:
album_info = {}
response = requests.get(''.join([ALBUM_SEARCH_URL, artist, "&a=", name]))
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return {}
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return {}
results = json.loads(response.content)
if results['album']:
album = results['album'][0]
album_info['theaudiodb_id'] = album.get('idAlbum')
album_info['theaudiodb_description'] = album.get('strDescriptionEN')
album_info['theaudiodb_genre'] = album.get('strGenre')
album_info['theaudiodb_style'] = album.get('strStyle')
album_info['theaudiodb_mood'] = album.get('strMood')
album_info['theaudiodb_speed'] = album.get('strSpeed')
album_info['theaudiodb_theme'] = album.get('strTheme')
album_info['theaudiodb_year_released'] = album.get('intYearReleased')
album_info['allmusic_id'] = album.get('strAllMusicID')
album_info['wikipedia_slug'] = album.get('strWikipediaID')
album_info['discogs_id'] = album.get('strDiscogsID')
album_info['wikidata_id'] = album.get('strWikidataID')
album_info['rateyourmusic_id'] = album.get('strRateYourMusicID')
if album.get('intScore'):
album_info['theaudiodb_score'] = float(album.get('intScore'))
if album.get('intScoreVotes'):
album_info['theaudiodb_score_votes'] = int(
album.get('intScoreVotes')
)
return album_info

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,45 @@ 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'),
path(
'imports/',
views.ScrobbleImportListView.as_view(),
name='import-detail',
),
path(
'imports/tsv/<slug:slug>/',
views.ScrobbleTSVImportDetailView.as_view(),
name='tsv-import-detail',
),
path(
'imports/lastfm/<slug:slug>/',
views.ScrobbleLastFMImportDetailView.as_view(),
name='lastfm-import-detail',
),
path(
'imports/koreader/<slug:slug>/',
views.ScrobbleKoReaderImportDetailView.as_view(),
name='koreader-import-detail',
),
path(
'charts/',
views.ChartRecordView.as_view(),
name='charts-home',
),
]

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

@ -1,24 +1,23 @@
import calendar
import json
import logging
from datetime import datetime
from datetime import datetime, timedelta
import pytz
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.db.models.fields import timezone
from django.db.models.query import QuerySet
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 import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
from music.aggregators import scrobble_counts, week_of_scrobbles
from rest_framework import status
from rest_framework.decorators import (
api_view,
@ -28,6 +27,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 +35,13 @@ 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,
ChartRecord,
KoReaderImport,
LastFmImport,
Scrobble,
)
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
@ -44,13 +50,15 @@ 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
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
from vrobbler.apps.music.aggregators import live_charts
logger = logging.getLogger(__name__)
@ -60,17 +68,7 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
user = self.request.user
now = timezone.now()
if user.is_authenticated:
if user.profile:
timezone.activate(pytz.timezone(user.profile.timezone))
now = timezone.localtime(timezone.now())
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
is_paused=False,
timestamp__lte=now,
user=user,
)
completed_for_user = Scrobble.objects.filter(
played_to_completion=True, user=user
@ -86,14 +84,11 @@ class RecentScrobbleList(ListView):
data['sport_scrobble_list'] = completed_for_user.filter(
sport_event__isnull=False
).order_by('-timestamp')[:15]
# data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(user, filter='week')
data['top_monthly_tracks'] = top_tracks(user, filter='month')
# data['top_daily_artists'] = top_artists()
data['top_weekly_artists'] = top_artists(user, filter='week')
data['top_monthly_artists'] = top_artists(user, filter='month')
data['active_imports'] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False,
processed_finished__isnull=True,
user=self.request.user,
)
data["weekly_data"] = week_of_scrobbles(user=user)
@ -108,6 +103,57 @@ class RecentScrobbleList(ListView):
).order_by('-timestamp')[:15]
class ScrobbleImportListView(TemplateView):
template_name = "scrobbles/import_list.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data['object_list'] = []
context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["koreader_imports"] = KoReaderImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["lastfm_imports"] = LastFmImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
return context_data
class BaseScrobbleImportDetailView(DetailView):
slug_field = 'uuid'
template_name = "scrobbles/import_detail.html"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
title = "Generic Scrobble Import"
if self.model == KoReaderImport:
title = "KoReader Import"
if self.model == AudioScrobblerTSVImport:
title = "Audioscrobbler TSV Import"
if self.model == LastFmImport:
title = "LastFM Import"
context_data['title'] = title
return context_data
class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView):
model = KoReaderImport
class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView):
model = AudioScrobblerTSVImport
class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView):
model = LastFmImport
class ManualScrobbleView(FormView):
form_class = ScrobbleForm
template_name = 'scrobbles/manual_form.html'
@ -173,6 +219,23 @@ class AudioScrobblerImportCreateView(
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
process_tsv_import.delay(self.object.id)
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())
@ -189,19 +252,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 (
@ -233,7 +287,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:
@ -267,7 +321,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(
@ -279,39 +335,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])
@ -332,3 +397,149 @@ def export(request):
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
class ChartRecordView(TemplateView):
template_name = 'scrobbles/chart_index.html'
@staticmethod
def get_media_filter(media_type: str = "") -> Q:
filters = {
"Track": Q(track__isnull=False),
"Artist": Q(artist__isnull=False),
"Series": Q(series__isnull=False),
"Video": Q(video__isnull=False),
"": Q(),
}
return filters[media_type]
def get_chart_records(self, media_type: str = "", **kwargs):
media_filter = self.get_media_filter(media_type)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs
).order_by("rank")
if charts.count() == 0:
ChartRecord.build(
user=self.request.user, model_str=media_type, **kwargs
)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs
).order_by("rank")
return charts
def get_chart(
self, period: str = "all_time", limit=15, media: str = ""
) -> QuerySet:
now = timezone.now()
params = {}
params['media_type'] = media
if period == "today":
params['day'] = now.day
params['month'] = now.month
params['year'] = now.year
if period == "week":
params['week'] = now.ioscalendar()[1]
params['year'] = now.year
if period == "month":
params['month'] = now.month
params['year'] = now.year
if period == "year":
params['year'] = now.year
return self.get_chart_records(**params)[:limit]
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
date = self.request.GET.get("date")
media_type = self.request.GET.get("media", "Track")
user = self.request.user
params = {}
context_data["artist_charts"] = {}
if not date:
artist_params = {'user': user, 'media_type': 'Artist'}
context_data['current_artist_charts'] = {
"today": live_charts(**artist_params, chart_period="today"),
"week": live_charts(**artist_params, chart_period="week"),
"month": live_charts(**artist_params, chart_period="month"),
"all": live_charts(**artist_params),
}
track_params = {'user': user, 'media_type': 'Track'}
context_data['current_track_charts'] = {
"today": live_charts(**track_params, chart_period="today"),
"week": live_charts(**track_params, chart_period="week"),
"month": live_charts(**track_params, chart_period="month"),
"all": live_charts(**track_params),
}
return context_data
# Date provided, lookup past charts, returning nothing if it's now or in the future.
now = timezone.now()
year = now.year
params = {'year': year}
name = f"Chart for {year}"
date_params = date.split('-')
year = int(date_params[0])
in_progress = False
if len(date_params) == 2:
if 'W' in date_params[1]:
week = int(date_params[1].strip('W"'))
params['week'] = week
start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace(
tzinfo=pytz.utc
)
end = start + timedelta(days=6)
in_progress = start <= now <= end
as_str = start.strftime('Week of %B %d, %Y')
name = f"Chart for {as_str}"
else:
month = int(date_params[1])
params['month'] = month
month_str = calendar.month_name[month]
name = f"Chart for {month_str} {year}"
in_progress = now.month == month and now.year == year
if len(date_params) == 3:
month = int(date_params[1])
day = int(date_params[2])
params['month'] = month
params['day'] = day
month_str = calendar.month_name[month]
name = f"Chart for {month_str} {day}, {year}"
in_progress = (
now.month == month and now.year == year and now.day == day
)
media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
media_filter = self.get_media_filter("Artist")
artist_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
if track_charts.count() == 0 and not in_progress:
ChartRecord.build(
user=self.request.user, model_str="Track", **params
)
media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
if artist_charts.count() == 0 and not in_progress:
ChartRecord.build(
user=self.request.user, model_str="Artist", **params
)
media_filter = self.get_media_filter("Artist")
artist_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
context_data['media_type'] = media_type
context_data['track_charts'] = track_charts
context_data['artist_charts'] = artist_charts
context_data['name'] = " ".join(["Top", media_type, "for", name])
context_data['in_progress'] = in_progress
return context_data

View File

View File

@ -0,0 +1,52 @@
from rest_framework import serializers
from sports.models import (
League,
SportEvent,
Round,
Player,
Team,
Season,
Sport,
)
class SportEventSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = SportEvent
fields = "__all__"
class LeagueSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = League
fields = "__all__"
class RoundSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Round
fields = "__all__"
class PlayerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Player
fields = "__all__"
class TeamSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Team
fields = "__all__"
class SeasonSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Season
fields = "__all__"
class SportSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Sport
fields = "__all__"

View File

@ -0,0 +1,61 @@
from rest_framework import permissions, viewsets
from sports.api.serializers import (
LeagueSerializer,
PlayerSerializer,
RoundSerializer,
SeasonSerializer,
SportEventSerializer,
SportSerializer,
TeamSerializer,
)
from sports.models import (
League,
Player,
Round,
Season,
Sport,
SportEvent,
Team,
)
class SportEventViewSet(viewsets.ModelViewSet):
queryset = SportEvent.objects.all().order_by('-created')
serializer_class = SportEventSerializer
permission_classes = [permissions.IsAuthenticated]
class LeagueViewSet(viewsets.ModelViewSet):
queryset = League.objects.all().order_by('-created')
serializer_class = LeagueSerializer
permission_classes = [permissions.IsAuthenticated]
class RoundViewSet(viewsets.ModelViewSet):
queryset = Round.objects.all().order_by('-created')
serializer_class = RoundSerializer
permission_classes = [permissions.IsAuthenticated]
class SportViewSet(viewsets.ModelViewSet):
queryset = Sport.objects.all().order_by('-created')
serializer_class = SportSerializer
permission_classes = [permissions.IsAuthenticated]
class PlayerViewSet(viewsets.ModelViewSet):
queryset = Player.objects.all().order_by('-created')
serializer_class = PlayerSerializer
permission_classes = [permissions.IsAuthenticated]
class TeamViewSet(viewsets.ModelViewSet):
queryset = Team.objects.all().order_by('-created')
serializer_class = TeamSerializer
permission_classes = [permissions.IsAuthenticated]
class SeasonViewSet(viewsets.ModelViewSet):
queryset = Season.objects.all().order_by('-created')
serializer_class = SeasonSerializer
permission_classes = [permissions.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

@ -6,6 +6,7 @@ from videos.models import Series, Video
class MovieListView(generic.ListView):
model = Video
template_name = "videos/movie_list.html"
def get_queryset(self):
return Video.objects.filter(video_type=Video.VideoType.MOVIE)

View File

@ -2,6 +2,7 @@ import os
import sys
from pathlib import Path
import dj_database_url
from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
@ -50,13 +51,9 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
# Used to dump data coming from srobbling sources, helpful for building new inputs
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
THESPORTSDB_BASE_URL = os.getenv(
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
)
THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
@ -71,6 +68,10 @@ CSRF_TRUSTED_ORIGINS = [
X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
if REDIS_URL:
print(f"Sending tasks to redis@{REDIS_URL.split('@')[-1]}")
else:
print("Eagerly running all tasks")
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
@ -97,6 +98,7 @@ INSTALLED_APPS = [
"music",
"podcasts",
"sports",
"books",
"mathfilters",
"rest_framework",
"allauth",
@ -134,11 +136,14 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"videos.context_processors.video_lists",
"music.context_processors.music_lists",
"scrobbles.context_processors.now_playing",
],
},
},
]
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
WSGI_APPLICATION = "vrobbler.wsgi.application"
DATABASES = {
@ -147,10 +152,18 @@ DATABASES = {
conn_max_age=600,
),
}
if TESTING:
DATABASES = {
"default": dj_database_url.config(default="sqlite:///testdb.sqlite3")
}
db_str = ""
if 'sqlite' in DATABASES['default']['ENGINE']:
db_str = f"Connected to sqlite@{DATABASES['default']['NAME']}"
if 'postgresql' in DATABASES['default']['ENGINE']:
db_str = f"Connected to postgres@{DATABASES['default']['HOST']}/{DATABASES['default']['NAME']}"
if db_str:
print(db_str)
CACHES = {
@ -170,22 +183,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 = "/"
@ -221,11 +231,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "static/"
STATIC_URL = "/static/"
STATIC_ROOT = os.getenv(
"VROBBLER_STATIC_ROOT", os.path.join(PROJECT_ROOT, "static")
)
MEDIA_URL = "/media/"
MEDIA_ROOT = os.getenv(
"VROBBLER_MEDIA_ROOT", os.path.join(PROJECT_ROOT, "media")

View File

@ -10,6 +10,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" type="image/png" href="{% static 'images/favicon.ico' %}"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.12.9/dist/umd/popper.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
@ -177,7 +178,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,30 +198,13 @@
<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 now_playing_list and user.is_authenticated %}
{% if messages %}
<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 %}
<small>{{scrobble.timestamp|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
</div>
<hr/>
{% endfor %}
{% for message in messages %}
<li {% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">
@ -228,6 +212,13 @@
Dashboard
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/charts/">
<span data-feather="bar-chart"></span>
Charts
</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/tracks/">
<span data-feather="music"></span>
@ -240,6 +231,12 @@
Artists
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/albums/">
<span data-feather="music"></span>
Albums
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/movies/">
<span data-feather="film"></span>
@ -249,10 +246,9 @@
<li class="nav-item">
<a class="nav-link" href="/series/">
<span data-feather="tv"></span>
Series
TV Shows
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/admin/">
<span data-feather="key"></span>
@ -263,6 +259,40 @@
</ul>
{% block extra_nav %}
{% endblock %}
<hr/>
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{% if scrobble.media_obj.album.cover_image %}
<td><img src="{{scrobble.track.album.cover_image.url}}" width=120 height=120 style="border:1px solid black; " /></td><br/>
{% endif %}
<a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a><br/>
{% if scrobble.media_obj.subtitle %}
<em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em><br/>
{% endif %}
<small>{{scrobble.timestamp|naturaltime}}<br/> from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
<hr />
</div>
{% endfor %}
</ul>
<hr/>
{% endif %}
{% if active_imports %}
{% for import in active_imports %}
<ul style="padding-right:10px;">
<li>Import in progress ({{import.processing_started|naturaltime}})</li>
</ul>
{% endfor %}
{% endif %}
</div>
</nav>
@ -271,6 +301,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

@ -1,8 +1,9 @@
{% 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">
<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">{% block title %}{% endblock %} </h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
@ -15,4 +16,4 @@
{% block lists %}{% endblock %}
</div>
</main>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,85 @@
{% extends "base_list.html" %}
{% load mathfilters %}
{% block title %}{{object.name}}{% endblock %}
{% block lists %}
<div class="row">
{% if object.cover_image %}
<p style="float:left; width:302px; padding:0; border: 1px solid #ccc">
<img src="{{object.cover_image.url}}" width=300 height=300 />
</p>
{% endif %}
<div style="float:left; width:600px; margin-left:10px; ">
{% if object.theaudiodb_description %}
<p>{{object.theaudiodb_description|safe|linebreaks|truncatewords:160}}</p>
<hr/>
{% endif %}
<p><a href="{{album.mb_link}}">Musicbrainz</a> {% if album.tadb_link %}| <a href="{{album.tadb_link}}">TheAudioDB</a>{% endif %} {% if album.allmusic_link %}| <a href="{{album.allmusic_link}}">AllMusic</a>{% endif %}</p>
</div>
</div>
<div class="row">
<p>{{object.scrobbles.count}} scrobbles</p>
{% if charts %}
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
{% endif %}
<div class="col-md">
<h3>Top tracks</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Count</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for track in object.tracks %}
<tr>
<td>{{rank}}#1</td>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td>{{track.scrobble_count}}</td>
<td>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist.name}}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,11 +1,82 @@
{% extends "base_list.html" %}
{% load urlreplace %}
{% block title %}Albums{% endblock %}
{% block lists %}
{% for album in object_list %}
<dl style="width: 130px; float: left; margin-right:10px;">
<dd><img src="{{album.cover_image.url}}" width=120 height=120 /></dd>
</dl>
{% endfor %}
<div class="row">
<p class="view">
<span class="view-links">
{% if view == 'grid' %}
View as <a href="?{% urlreplace view='list' %}">List</a>
{% else %}
View as <a href="?{% urlreplace view='grid' %}">Grid</a>
{% endif %}
</span>
</p>
<p class="pagination">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?{% urlreplace page=page_obj.previous_page_number %}">prev</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?{% urlreplace page=page_obj.next_page_number %}">next</a>
{% endif %}
</span>
</p>
<hr />
{% if view == 'grid' %}
<div>
{% for album in object_list %}
{% if album.cover_image %}
<dl style="width: 130px; float: left; margin-right:10px;">
<dd><img src="{{album.cover_image.url}}" width=120 height=120 /></dd>
</dl>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Album</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for album in object_list %}
<tr>
<td><a href="{{album.get_absolute_url}}">{{album}}</a></td>
<td><a href="{{album.artist.get_absolute_url}}">{{album.artist}}</a></td>
<td>{{album.scrobbles.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="pagination" style="margin-bottom:50px;">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?{% urlreplace page=page_obj.previous_page_number %}">prev</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?{% urlreplace page=page_obj.next_page_number %}">next</a>
{% endif %}
</span>
</div>
</div>
{% endblock %}

View File

@ -1,19 +1,35 @@
{% extends "base_detail.html" %}
{% extends "base_list.html" %}
{% load mathfilters %}
{% block title %}{{object.name}}{% endblock %}
{% block details %}
{% block lists %}
<div class="row">
{% for album in artist.album_set.all %}
{% if album.cover_image %}
<p style="width:150px; float:left;"><img src="{{album.cover_image.url}}" width=150 height=150 /></p>
{% if object.thumbnail %}
<p style="float:left; width:300px; margin-right:10px;">
<img style="border:1px solid #ccc;" src="{{artist.thumbnail.url}}" width=300 height=300 />
</p>
{% else %}
{% if object.album_set.first.cover_image %}
<p style="float:left; width:302px; padding:0; border: 1px solid #ccc">
<img src="{{object.album_set.first.cover_image.url}}" width=300 height=300 />
</p>
{% endif %}
{% endfor %}
{% endif %}
<div style="float:left; width:600px; margin-left:10px; ">
{% if artist.biography %}
<p>{{artist.biography|safe|linebreaks|truncatewords:160}}</p>
<hr/>
{% endif %}
<p><a href="{{artist.mb_link}}">Musicbrainz</a></p>
</div>
</div>
<div class="row">
<p>{{artist.scrobbles.count}} scrobbles</p>
{% if charts %}
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
{% endif %}
<div class="col-md">
<h3>Top tracks</h3>
<div class="table-responsive">
@ -22,6 +38,7 @@
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Album</th>
<th scope="col">Count</th>
<th scope="col"></th>
</tr>
@ -30,7 +47,8 @@
{% for track in object.tracks %}
<tr>
<td>{{rank}}#1</td>
<td>{{track.title}}</td>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
<td><a href="{{track.album.get_absolute_url}}">{{track.album}}</a></td>
<td>{{track.scrobble_count}}</td>
<td>
<div class="progress-bar" style="margin-right:5px;">
@ -60,8 +78,8 @@
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.album.name}}</td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -1,9 +1,45 @@
{% extends "base_list.html" %}
{% load urlreplace %}
{% block title %}Artists{% endblock %}
{% block lists %}
<div class="row">
<p class="view">
<span class="view-links">
{% if view == 'grid' %}
View as <a href="?{% urlreplace view='list' %}">List</a>
{% else %}
View as <a href="?{% urlreplace view='grid' %}">Grid</a>
{% endif %}
</span>
</p>
<p class="pagination">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?{% urlreplace page=page_obj.previous_page_number %}">prev</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?{% urlreplace page=page_obj.next_page_number %}">next</a>
{% endif %}
</span>
</p>
<hr />
{% if view == 'grid' %}
<div>
{% for artist in object_list %}
{% if artist.thumbnail %}
<dl style="width: 130px; float: left; margin-right:10px;">
<dd><img src="{{artist.thumbnail.url}}" width=120 height=120 /></dd>
</dl>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
@ -26,5 +62,20 @@
</table>
</div>
</div>
{% endif %}
<div class="pagination" style="margin-bottom:50px;">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?{% urlreplace page=page_obj.previous_page_number %}">prev</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?{% urlreplace page=page_obj.next_page_number %}">next</a>
{% endif %}
</span>
</div>
</div>
{% endblock %}

View File

@ -1,13 +1,43 @@
{% extends "base_detail.html" %}
{% extends "base_list.html" %}
{% block title %}{{object.title}}{% endblock %}
{% block details %}
<h2>Last scrobbles</h2>
{% for scrobble in object.scrobble_set.all %}
<ul>
<li>{{scrobble.timestamp|date:"d M Y h:m"}} - <img src="{{object.album.cover_image.url}}" width=25 height=25 /> - {{object}}</li>
</ul>
{% endfor %}
{% block lists %}
<div class="row">
{% if track.album.cover_image %}
<p style="width:150px; float:left;"><img src="{{track.album.cover_image.url}}" width=150 height=150 /></p>
{% endif %}
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
{% if charts %}
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
{% endif %}
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Track</th>
<th scope="col">Album</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist}}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,10 +3,57 @@
{% block title %}Tracks{% endblock %}
{% block lists %}
<h2>All time</h2>
{% for track in object_list %}
<ul>
<li><a href="{{track.get_absolute_url}}">{{track}}</a></li>
</ul>
{% endfor %}
<div class="row">
<p class="pagination">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
</span>
</p>
<hr />
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for track in object_list %}
<tr>
<td><a href="{{track.get_absolute_url}}">{{track}}</a></td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td>{{track.scrobble_set.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="pagination" style="margin-bottom:50px;">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
</span>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,150 @@
{% extends "base_list.html" %}
{% block title %}{{name}}{% endblock %}
{% block lists %}
<div class="row">
{% if artist_charts %}
<div class="col-md">
<div class="tab-content" id="artistTabContent">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in artist_charts %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.media_obj.get_absolute_url}}">{{chart.media_obj}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if track_charts %}
<div class="col-md">
<div class="tab-content" id="artistTabContent">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in track_charts %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.media_obj.get_absolute_url}}">{{chart.media_obj.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if current_artist_charts %}
<div class="col-md">
<h2>Top Artists</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for chart_name in current_artist_charts.keys %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="artist-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{chart_name}}"
type="button" role="tab" aria-controls="home" aria-selected="true">
{{chart_name}}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="artistTabContent">
{% for chart_name, artists in current_artist_charts.items %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="artist-{{chart_name}}" role="tabpanel"
aria-labelledby="artist-{[chart}}-tab">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for artist in artists %}
<tr>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if current_track_charts %}
<div class="col-md">
<h2>Top Tracks</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for chart_name in current_track_charts.keys %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="track-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{chart_name}}"
type="button" role="tab" aria-controls="home" aria-selected="true">
{{chart_name}}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="trackTabContent">
{% for chart_name, tracks in current_track_charts.items %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="track-{{chart_name}}" role="tabpanel"
aria-labelledby="track-{[chart_name}}-tab">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td>{{track.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_detail.html" %}
{% block title %}{{title}}{% endblock %}
{% block details %}
<div class="row">
<div class="col-md">
<p>Import started: {{object.processing_started}}</p>
<p>Import finished: {{object.processed_finished}}</p>
<p>Imported {{object.process_count}} scrobbles</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "base_list.html" %}
{% block title %}Scrobble Imports{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Date</th>
<th scope="col">Scrobbles Imported</th>
<th scope="col">Finished</th>
</tr>
</thead>
<tbody>
{% for obj in tsv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in lastfm_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in koreader_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

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>
@ -46,260 +46,130 @@
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div>
<div class="row">
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab"
data-bs-target="#artists-week" type="button" role="tab" aria-controls="home"
aria-selected="true">Weekly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab"
data-bs-target="#artists-month" type="button" role="tab" aria-controls="home"
aria-selected="true">Monthly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week"
type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly
Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month"
type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly
Tracks</button>
</li>
</ul>
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened"
type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted"
type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="artists-week" role="tabpanel"
aria-labelledby="artists-week-tab">
<h2>Top artists this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_weekly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-content" id="myTabContent2">
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
aria-labelledby="latest-listened-tab">
<h2>Latest listened</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Album</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.track.album.cover_image %}
<td><a href="{{scrobble.track.album.get_absolute_url}}"><img src="{{scrobble.track.album.cover_image.url}}" width=25 height=25 style="border:1px solid black;" /></aa></td>
{% else %}
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td>
{% endif %}
<td><a href="{{scrobble.track.get_absolute_url }}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url }}">{{scrobble.track.artist.name}}</aa></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade show" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
<h2>Top tracks this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_weekly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="tracks-month" role="tabpanel"
aria-labelledby="tracks-month-tab">
<h2>Top tracks this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_monthly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show " id="artists-month" role="tabpanel"
aria-labelledby="artists-month-tab">
<h2>Top artists this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_monthly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab"
data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home"
aria-selected="true">Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab"
data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile"
aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li>
</ul>
<div class="tab-content" id="myTabContent2">
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
aria-labelledby="latest-listened-tab">
<h2>Latest listened</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Album</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.track.album.cover_image %}
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50
style="border:1px solid black;" /></td>
{% else %}
<td>{{scrobble.track.album.name}}</td>
{% endif %}
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
aria-labelledby="latest-watched-tab">
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
aria-labelledby="latest-watched-tab">
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">League</th>
</tr>
</thead>
<tbody>
{% for scrobble in sport_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td>
<td>{{scrobble.sport_event.league.abbreviation}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel"
aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">League</th>
</tr>
</thead>
<tbody>
{% for scrobble in sport_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td>
<td>{{scrobble.sport_event.league.abbreviation}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Podcast</th>
</tr>
</thead>
<tbody>
{% for scrobble in podcast_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.podcast_episode.podcast}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Podcast</th>
</tr>
</thead>
<tbody>
{% for scrobble in podcast_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.podcast_episode.podcast}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
@ -316,7 +186,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 +196,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

@ -1,28 +1,28 @@
{% extends "base.html" %}
{% extends "base_list.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">Movies</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
<span data-feather="calendar"></span>
This week
</button>
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{obj.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="container">
<ul>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %}
</ul>
</div>
</main>
{% endblock %}
</div>
{% endblock %}

View File

@ -1,22 +1,32 @@
{% extends "base.html" %}
{% extends "base_list.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">Series</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div>
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Series</th>
<th scope="col">Episode</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
{% for video in obj.video_set.all %}
<tr>
<td><a href="{{video.get_absolute_url}}">{{video}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{video.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="container">
<ul>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %}
</ul>
</div>
</main>
{% endblock %}
</div>
{% endblock %}

View File

@ -1,14 +1,37 @@
{% extends "base.html" %}
{% extends "base_detail.html" %}
{% block title %}Videos{% endblock %}
{% block title %}{{object.name}}{% endblock %}
{% block content %}
{{object}}
{% block details %}
{% for scrobble in object.scrobble_set.all %}
<ul>
<li>{{scrobble}}</li>
</ul>
{% endfor %}
{% endblock %}
<div class="row">
<h2>{{object.tv_series}}</h2>
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
<th scope="col">Season</th>
<th scope="col">Episode</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td>{{scrobble.video.title}}</td>
<td>{{scrobble.video.tv_series}}</td>
<td>{{scrobble.video.season_number}}</td>
<td>{{scrobble.video.episode_number}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% block title %}Movies{% endblock %}
{% block content %}
{% for movie in object_list %}
<dl>
<dt>{{movie}}</dt>
{% for scrobble in movie.scrobble_set.all %}
<dd>{{scrobble}}</dd>
{% endfor %}
</dl>
{% endfor %}
{% endblock %}

View File

@ -1,40 +1,64 @@
import scrobbles.views as scrobbles_views
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
import vrobbler.apps.scrobbles.views as scrobbles_views
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.sports.api.views import (
LeagueViewSet,
PlayerViewSet,
SeasonViewSet,
SportEventViewSet,
SportViewSet,
TeamViewSet,
)
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'leagues', LeagueViewSet)
router.register(r'sports', SportViewSet)
router.register(r'seasons', SeasonViewSet)
router.register(r'players', PlayerViewSet)
router.register(r'sport-events', SportEventViewSet)
router.register(r'teams', TeamViewSet)
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"
),
]
if settings.DEBUG:
urlpatterns += static(
settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
)
urlpatterns += static(
settings.STATIC_URL, document_root=settings.STATIC_ROOT
)