Compare commits

...

26 Commits

Author SHA1 Message Date
1a0c4f69f0 Merge branch 'develop' 2025-06-09 11:26:25 -04:00
356e579558 [brickset] Little fix to scrobble of bricksets 2025-06-09 10:40:12 -04:00
e980e3c5c9 [brickset] Fix manual scrobbling and admin 2025-06-09 10:36:13 -04:00
8773542099 [tasks] Fix source being orgmode not emacs 2025-06-08 03:05:43 -04:00
70378c9968 [tasks] Fix updating and stopping tasks from Emacs 2025-06-08 02:40:07 -04:00
c871087496 [tasks] First try at adding an emacs webhook 2025-06-08 01:09:03 -04:00
059d7780a0 [templates] Add counts and durations to new tables 2025-06-07 20:02:21 -04:00
db36329011 Merge branch 'develop' 2025-06-07 18:11:08 -04:00
69aa80e6c1 [templates] Allow going back and forward in time 2025-06-07 15:17:56 -04:00
99a6e5107b [scrobbles] Add orgparse and start of emacs webhook 2025-06-06 17:25:04 -04:00
39e2fdce27 [music] Fix allmusic lookup 2025-06-06 17:24:41 -04:00
b2f98d780b [middleware] Add auto timezone setting 2025-06-06 09:49:53 -04:00
07b5dc6a2c [templates] Clean up dashboard 2025-06-06 09:49:08 -04:00
7207ca385e [scrobbles] Add two new methods to Scrobble model 2025-06-06 09:48:15 -04:00
2a254e28d0 [templates] Start redesigning the dashboard 2025-05-18 23:20:47 -04:00
d4377e49ac [music] Fix getting album metadata 2025-05-18 23:19:17 -04:00
9dc0a818ff [templates] Fix chart loading 2025-05-18 23:19:05 -04:00
5e672fc9ed [webpages] Add title to webpage reading 2025-05-18 09:37:53 -04:00
6d194d227e Merge branch 'develop' 2025-05-11 01:43:55 -04:00
ed217cbad2 [beers] Blacken and clean up imports 2025-05-11 01:42:30 -04:00
3cca30dc70 [puzzles] Add puzzle lookup via IPDB 2025-05-11 01:41:43 -04:00
d3a15d5e7b Merge branch 'develop' 2025-05-11 00:03:48 -04:00
deeaa0af4b [puzzles] Fix misnamed ipdb field 2025-05-10 23:58:44 -04:00
dd71bdd38c [puzzles] Add puzzle model and hooks 2025-05-10 23:36:03 -04:00
d066a98282 [importers] Fixes TSV comma missing 2025-04-15 16:32:14 -04:00
73bc4a1cd1 [webpages] Fixes 500 errors when webpage lookup fails 2025-04-15 16:18:26 -04:00
30 changed files with 1221 additions and 499 deletions

15
poetry.lock generated
View File

@ -2228,6 +2228,7 @@ python-versions = "*"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "jusText-3.0.1-py2.py3-none-any.whl", hash = "sha256:e0fb882dd7285415709f4b7466aed23d6b98b7b89404c36e8a2e730facfed02b"}, {file = "jusText-3.0.1-py2.py3-none-any.whl", hash = "sha256:e0fb882dd7285415709f4b7466aed23d6b98b7b89404c36e8a2e730facfed02b"},
{file = "justext-3.0.1-py2.py3-none-any.whl", hash = "sha256:0a5225c5cd7c5a124fec7bfa9a55110a73135e8b58ce784470af67d051ac9fd3"},
{file = "justext-3.0.1.tar.gz", hash = "sha256:b6ed2fb6c5d21618e2e34b2295c4edfc0bcece3bd549ed5c8ef5a8d20f0b3451"}, {file = "justext-3.0.1.tar.gz", hash = "sha256:b6ed2fb6c5d21618e2e34b2295c4edfc0bcece3bd549ed5c8ef5a8d20f0b3451"},
] ]
@ -2891,6 +2892,18 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"] signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "orgparse"
version = "0.4.20250520"
description = "orgparse - Emacs org-mode parser in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orgparse-0.4.20250520-py3-none-any.whl", hash = "sha256:24d454432385016ae91c3518c8357a3a31fdd4cebfbb0c5926cf31247bf4c7e3"},
{file = "orgparse-0.4.20250520.tar.gz", hash = "sha256:6472fd16ddcabb523918505c865263abfa05ac80b593e668a089cda291b1a2de"},
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "24.2" version = "24.2"
@ -5439,4 +5452,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9,<3.12" python-versions = ">=3.9,<3.12"
content-hash = "17358679b06dd15b7f119307013ed578ef81dbf31c592bc2ca11d13787a81215" content-hash = "cdd7f577fe3a4c5c8cc960e0070d93b7ddbb2a7968fab63d72bb039afaa05bbe"

View File

@ -53,6 +53,7 @@ django-oauth-toolkit = "^3.0.1"
meta-yt = "^0.1.9" meta-yt = "^0.1.9"
berserk = "^0.13.2" berserk = "^0.13.2"
poetry-bumpversion = "^0.3.3" poetry-bumpversion = "^0.3.3"
orgparse = "^0.4.20250520"
[tool.poetry.group.test] [tool.poetry.group.test]
optional = true optional = true

View File

@ -1,6 +1,4 @@
import json
import logging import logging
import urllib
from typing import Optional from typing import Optional
import requests import requests
@ -87,7 +85,9 @@ def get_ibu_from_soup(soup) -> Optional[int]:
def get_rating_from_soup(soup) -> str: def get_rating_from_soup(soup) -> str:
rating = "" rating = ""
try: try:
rating = float(soup.find(class_="num").get_text().strip("(").strip(")")) rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError: except AttributeError:
rating = None rating = None
except ValueError: except ValueError:
@ -130,7 +130,6 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
return beer_dict return beer_dict
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
beer_dict["untappd_id"] = untappd_id
beer_dict["title"] = get_title_from_soup(soup) beer_dict["title"] = get_title_from_soup(soup)
beer_dict["description"] = get_description_from_soup(soup) beer_dict["description"] = get_description_from_soup(soup)
beer_dict["styles"] = get_styles_from_soup(soup) beer_dict["styles"] = get_styles_from_soup(soup)

View File

@ -16,7 +16,9 @@ class BrickSet(LongPlayScrobblableMixin):
number = models.CharField(max_length=10, **BNULL) number = models.CharField(max_length=10, **BNULL)
release_year = models.IntegerField(**BNULL) release_year = models.IntegerField(**BNULL)
piece_count = models.IntegerField(**BNULL) piece_count = models.IntegerField(**BNULL)
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL) brickset_rating = models.DecimalField(
max_digits=3, decimal_places=1, **BNULL
)
lego_item_number = models.CharField(max_length=10, **BNULL) lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL) box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField( box_image_small = ImageSpecField(
@ -48,13 +50,23 @@ class BrickSet(LongPlayScrobblableMixin):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid}) return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
def __str__(self) -> str:
name = str(self.title)
if not self.title:
name = str(self.number)
return name
@property @property
def logdata_cls(self): def logdata_cls(self):
return BrickSetLogData return BrickSetLogData
@classmethod @classmethod
def find_or_create(cls, title: str) -> "BrickSet": def find_or_create(cls, brickset_id: str) -> "BrickSet":
return cls.objects.filter(title=title).first() brickset = cls.objects.filter(number=brickset_id).first()
if not brickset:
# TODO: enrich this from the website
brickset = cls.objects.create(number=brickset_id)
return brickset
@property @property
def primary_image_url(self) -> str: def primary_image_url(self) -> str:

View File

@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
"theaudiodb_mood", "theaudiodb_mood",
"musicbrainz_id", "musicbrainz_id",
) )
raw_id_fields = ("album_artist",)
list_filter = ( list_filter = (
"theaudiodb_score", "theaudiodb_score",
"theaudiodb_genre", "theaudiodb_genre",
@ -53,6 +54,10 @@ class TrackAdmin(admin.ModelAdmin):
"artist", "artist",
"musicbrainz_id", "musicbrainz_id",
) )
raw_id_fields = (
"album",
"artist",
)
list_filter = ("album", "artist") list_filter = ("album", "artist")
search_fields = ("title",) search_fields = ("title",)
ordering = ("-created",) ordering = ("-created",)

View File

@ -307,8 +307,15 @@ class Album(TimeStampedModel):
self.save(update_fields=["album_artist"]) self.save(update_fields=["album_artist"])
def scrape_allmusic(self, force=False) -> None: def scrape_allmusic(self, force=False) -> None:
if not self.name:
logger.warning(
"Album without a name cannot be scraped",
extra={"album_id": self.id},
)
return
if not self.allmusic_id or force: if not self.allmusic_id or force:
slug = get_allmusic_slug(self.name, self.album_artist.name) slug = get_allmusic_slug(self.album_artist.name, self.name)
if not slug: if not slug:
logger.info( logger.info(
f"No allmsuic link for {self} by {self.album_artist}" f"No allmsuic link for {self} by {self.album_artist}"
@ -317,7 +324,9 @@ class Album(TimeStampedModel):
self.allmusic_id = slug self.allmusic_id = slug
self.save(update_fields=["allmusic_id"]) self.save(update_fields=["allmusic_id"])
allmusic_data = scrape_data_from_allmusic(self.allmusic_link) allmusic_data = None
if self.allmusic_link:
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
if not allmusic_data: if not allmusic_data:
logger.info(f"No allmsuic data for {self} by {self.album_artist}") logger.info(f"No allmsuic data for {self} by {self.album_artist}")

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.19 on 2025-05-11 03:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("puzzles", "0002_alter_puzzle_dimensions_in_inches"),
]
operations = [
migrations.RenameField(
model_name="puzzle",
old_name="igdb_id",
new_name="ipdb_id",
),
migrations.RemoveField(
model_name="puzzle",
name="igdb_image",
),
migrations.AddField(
model_name="puzzle",
name="ipdb_image",
field=models.ImageField(
blank=True, null=True, upload_to="puzzles/ipdb/"
),
),
]

View File

@ -1,12 +1,14 @@
from uuid import uuid4 from uuid import uuid4
from puzzles.sources import ipdb import requests
from django.apps import apps from django.apps import apps
from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from puzzles.sources import ipdb
from scrobbles.dataclasses import PuzzleLogData from scrobbles.dataclasses import PuzzleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
@ -31,17 +33,17 @@ class Puzzle(ScrobblableMixin):
material = models.CharField(max_length=50, **BNULL) material = models.CharField(max_length=50, **BNULL)
cut_style = models.CharField(max_length=100, **BNULL) cut_style = models.CharField(max_length=100, **BNULL)
pieces_count = models.IntegerField(**BNULL) pieces_count = models.IntegerField(**BNULL)
igdb_id = models.CharField(max_length=255, **BNULL) ipdb_id = models.CharField(max_length=255, **BNULL)
barcode = models.CharField(max_length=13, **BNULL) barcode = models.CharField(max_length=13, **BNULL)
igdb_image = models.ImageField(upload_to="puzzles/igdb/", **BNULL) ipdb_image = models.ImageField(upload_to="puzzles/ipdb/", **BNULL)
igdb_image_small = ImageSpecField( ipdb_image_small = ImageSpecField(
source="igdb_image", source="ipdb_image",
processors=[ResizeToFit(100, 100)], processors=[ResizeToFit(100, 100)],
format="JPEG", format="JPEG",
options={"quality": 60}, options={"quality": 60},
) )
igdb_image_medium = ImageSpecField( ipdb_image_medium = ImageSpecField(
source="igdb_image", source="ipdb_image",
processors=[ResizeToFit(300, 300)], processors=[ResizeToFit(300, 300)],
format="JPEG", format="JPEG",
options={"quality": 75}, options={"quality": 75},
@ -65,10 +67,10 @@ class Puzzle(ScrobblableMixin):
return ScrobblableConstants(verb="Solving", tags="puzzle") return ScrobblableConstants(verb="Solving", tags="puzzle")
@property @property
def igdb_link(self) -> str: def ipdb_link(self) -> str:
link = "" link = ""
if self.igdb_id: if self.ipdb_id:
link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.igdb_id}" link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.ipdb_id}"
return link return link
@property @property
@ -88,17 +90,31 @@ class Puzzle(ScrobblableMixin):
if not puzzle: if not puzzle:
puzzle_dict = ipdb.get_puzzle_from_ipdb_id(ipdb_id) puzzle_dict = ipdb.get_puzzle_from_ipdb_id(ipdb_id)
manufacturer_name = puzzle_dict.pop("manufacturer") manufacturer_name = puzzle_dict.pop("manufacturer", None)
manufacturer, _created = PuzzleManufacturer.objects.get_or_create( if manufacturer_name:
name=manufacturer_name (
) manufacturer,
ipdb_dict["manufacturer_id"] = manufacturer.id _created,
) = PuzzleManufacturer.objects.get_or_create(
name=manufacturer_name
)
puzzle_dict["manufacturer_id"] = manufacturer.id
genres = puzzle_dict.pop("genres") genres = puzzle_dict.pop("genres", None)
cover_url = puzzle_dict.pop("ipdb_image_url", None)
puzzle = Puzzle.objects.create(**puzzle_dict) puzzle = Puzzle.objects.create(**puzzle_dict)
if genres: if genres:
puzzle.genre.add(*genres) puzzle.genre.add(*genres)
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{puzzle.title}_{puzzle.uuid}.jpg"
puzzle.ipdb_image.save(
fname, ContentFile(r.content), save=True
)
return puzzle return puzzle
def scrobbles(self, user_id): def scrobbles(self, user_id):

View File

@ -1,5 +1,246 @@
#!/usr/bin/env python3 import logging
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
IPDB_URL = "https://www.ipdb.plus/IPDb/puzzle.php?id={id}"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text()
except AttributeError:
pass
except ValueError:
pass
return title
def get_manufacturer_from_soup(soup) -> str:
manufacturer = ""
try:
manufacturer = (
soup.find(class_="infobox").div.contents[0].split("|")[0].strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return manufacturer
def get_pieces_count_from_soup(soup) -> int:
pieces = 0
try:
pieces = int(
soup.find(class_="infobox")
.div.contents[0]
.split("|")[1]
.split(" ")[1]
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return pieces
def get_publish_year_from_soup(soup) -> int:
year = 1900
try:
year = int(
soup.find(class_="infobox").div.contents[0].split("|")[2].strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return year
def get_material_from_soup(soup) -> str:
material = ""
try:
material = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[2]
.split("|")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return material
def get_cut_style_from_soup(soup) -> str:
cut_style = ""
try:
cut_style = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[2]
.split("|")[-2]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return cut_style
def get_barcode_from_soup(soup) -> str:
barcode = ""
try:
barcode = (
soup.findAll(class_="infobox")[2]
.findAll("div")[0]
.contents[0]
.split("|")[-1]
.split(":")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
if not barcode:
try:
barcode = (
soup.findAll(class_="infobox")[3]
.findAll("div")[0]
.contents[0]
.split("|")[-1]
.split(":")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return barcode
def get_image_url_from_soup(soup) -> str:
url = ""
try:
url = (
soup.find(class_="image-container").contents[0].contents[0]["src"]
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return url
def get_orientation_from_soup(soup) -> str:
orientation = ""
try:
orientation = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[0]
.split("|")[0]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return orientation
def get_dimensions_from_soup(soup) -> str:
dimensions = ""
try:
height = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[1]["data-bs-height"]
)
width = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[1]["data-bs-width"]
)
dimensions = "x".join([height, width])
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return dimensions
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError:
rating = None
except ValueError:
rating = None
return rating
def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict: def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict:
return {} puzzle_url = IPDB_URL.format(id=ipdb_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(puzzle_url, headers=headers)
puzzle_dict = {"ipdb_id": ipdb_id}
if response.status_code != 200:
logger.warn(
"Bad response from untappd.com", extra={"response": response}
)
return puzzle_dict
soup = BeautifulSoup(response.text, "html.parser")
puzzle_dict["title"] = get_title_from_soup(soup)
puzzle_dict["orientation"] = get_orientation_from_soup(soup)
puzzle_dict["manufacturer"] = get_manufacturer_from_soup(soup)
puzzle_dict["pieces_count"] = get_pieces_count_from_soup(soup)
puzzle_dict["ipdb_image_url"] = get_image_url_from_soup(soup)
puzzle_dict["dimensions_in_inches"] = get_dimensions_from_soup(soup)
puzzle_dict["publish_year"] = get_publish_year_from_soup(soup)
puzzle_dict["material"] = get_material_from_soup(soup)
puzzle_dict["cut_style"] = get_cut_style_from_soup(soup)
puzzle_dict["barcode"] = get_barcode_from_soup(soup)
return puzzle_dict

View File

@ -27,7 +27,7 @@ class ScrobbleInline(admin.TabularInline):
"geo_location", "geo_location",
"task", "task",
"mood", "mood",
"brickset", "brick_set",
"trail", "trail",
"beer", "beer",
"web_page", "web_page",
@ -122,9 +122,12 @@ class ScrobbleAdmin(admin.ModelAdmin):
"video_game", "video_game",
"board_game", "board_game",
"geo_location", "geo_location",
"puzzle",
"paper",
"food",
"task", "task",
"mood", "mood",
"brickset", "brick_set",
"trail", "trail",
"beer", "beer",
"web_page", "web_page",

View File

@ -27,14 +27,14 @@ MEDIA_END_PADDING_SECONDS = {
TODOIST_TASK_URL = "https://app.todoist.com/app/task/{id}" TODOIST_TASK_URL = "https://app.todoist.com/app/task/{id}"
SCROBBLE_CONTENT_URLS = { SCROBBLE_CONTENT_URLS = {
"-i": "https://www.imdb.com/title/", "-i": ["https://www.imdb.com/title/", "https://www.youtube.com/watch?v="],
"-s": "https://www.thesportsdb.com/event/", "-s": ["https://www.thesportsdb.com/event/"],
"-g": "https://boardgamegeek.com/boardgame/", "-g": ["https://boardgamegeek.com/boardgame/"],
"-u": "https://untappd.com/", "-u": ["https://untappd.com/"],
"-b": "https://www.amazon.com/", "-b": ["https://www.amazon.com/"],
"-t": "https://app.todoist.com/app/task/{id}", "-t": ["https://app.todoist.com/app/task/{id}"],
"-i": "https://www.youtube.com/watch?v=", "-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
"-p": "https://www.ipdb.plus/IPDb/puzzle.php?id=", "-l": ["https://brickset.com/sets/"],
} }
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",) EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
@ -49,6 +49,7 @@ MANUAL_SCROBBLE_FNS = {
"-w": "manual_scrobble_webpage", "-w": "manual_scrobble_webpage",
"-t": "manual_scrobble_task", "-t": "manual_scrobble_task",
"-p": "manual_scrobble_puzzle", "-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset",
} }

View File

@ -0,0 +1,28 @@
import logging
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
class TimezoneMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def __getUserTimeZone(self, request):
# info = IPResolver(request).getGeoInfo()
# return pytz.country_timezones[info["country_code"]][0]
if not request.user.is_anonymous:
return request.user.profile.timezone
def process_request(self, request):
try:
tz = self.__getUserTimeZone(request) or settings.TIME_ZONE
timezone.activate(tz)
logger.info('Time zone "%s" activated' % str(tz))
except Exception as e:
logger.error("Unable to set timezone: %s" % str(e))

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-06-09 14:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0069_scrobble_puzzle_alter_scrobble_media_type"),
]
operations = [
migrations.RenameField(
model_name="scrobble",
old_name="brickset",
new_name="brick_set",
),
]

View File

@ -2,6 +2,7 @@ import calendar
import datetime import datetime
import json import json
import logging import logging
from collections import defaultdict
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
@ -514,6 +515,10 @@ class Scrobble(TimeStampedModel):
MOOD = "Mood", "Mood" MOOD = "Mood", "Mood"
BRICKSET = "BrickSet", "Brick set" BRICKSET = "BrickSet", "Brick set"
@classmethod
def list(cls):
return list(map(lambda c: c.value, cls))
uuid = models.UUIDField(editable=False, **BNULL) uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL) track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@ -544,7 +549,7 @@ class Scrobble(TimeStampedModel):
LifeEvent, on_delete=models.DO_NOTHING, **BNULL LifeEvent, on_delete=models.DO_NOTHING, **BNULL
) )
mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL) mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
brickset = models.ForeignKey( brick_set = models.ForeignKey(
BrickSet, on_delete=models.DO_NOTHING, **BNULL BrickSet, on_delete=models.DO_NOTHING, **BNULL
) )
media_type = models.CharField( media_type = models.CharField(
@ -597,6 +602,59 @@ class Scrobble(TimeStampedModel):
long_play_seconds = models.BigIntegerField(**BNULL) long_play_seconds = models.BigIntegerField(**BNULL)
long_play_complete = models.BooleanField(**BNULL) long_play_complete = models.BooleanField(**BNULL)
@classmethod
def for_year(cls, user, year):
return cls.objects.filter(timestamp__year=year, user=user).order_by(
"-timestamp"
)
@classmethod
def for_month(cls, user, year, month):
return cls.objects.filter(
timestamp__year=year, timestamp__month=month, user=user
).order_by("-timestamp")
@classmethod
def for_day(cls, user, year, month, day):
return cls.objects.filter(
timestamp__year=year,
timestamp__month=month,
timestamp__day=day,
user=user,
).order_by("-timestamp")
@classmethod
def for_week(cls, user, year, week):
return cls.objects.filter(
timestamp__year=year, timestamp__week=week, user=user
).order_by("-timestamp")
@classmethod
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
scrobbles_by_type = defaultdict(list)
for scrobble in scrobble_qs:
scrobbles_by_type[scrobble.media_type].append(scrobble)
if not scrobbles_by_type.get(scrobble.media_type + "_count"):
scrobbles_by_type[scrobble.media_type + "_count"] = 0
scrobbles_by_type[scrobble.media_type + "_count"] += 1
if not scrobbles_by_type.get(scrobble.media_type + "_time"):
scrobbles_by_type[scrobble.media_type + "_time"] = 0
scrobbles_by_type[scrobble.media_type + "_time"] += int(
(scrobble.elapsed_time)
)
return scrobbles_by_type
@classmethod
def in_progress_for_user(cls, user_id: int) -> models.QuerySet:
return cls.objects.filter(
user=user_id,
in_progress=True,
played_to_completion=False,
is_paused=False,
)
@property @property
def last_serial_scrobble(self) -> Optional["Scrobble"]: def last_serial_scrobble(self) -> Optional["Scrobble"]:
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@ -788,6 +846,15 @@ class Scrobble(TimeStampedModel):
def is_long_play(self) -> bool: def is_long_play(self) -> bool:
return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values() return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
@property
def elapsed_time(self) -> int | None:
if self.played_to_completion:
if self.playback_position_seconds:
return self.playback_position_seconds
if self.media_obj.run_time_seconds:
return self.media_obj.run_time_seconds
return (timezone.now() - self.timestamp).seconds
@property @property
def percent_played(self) -> int: def percent_played(self) -> int:
if not self.media_obj: if not self.media_obj:
@ -801,7 +868,7 @@ class Scrobble(TimeStampedModel):
playback_seconds = self.playback_position_seconds playback_seconds = self.playback_position_seconds
if not playback_seconds: if not playback_seconds:
playback_seconds = (timezone.now() - self.timestamp).seconds playback_seconds = self.elapsed_time
run_time_secs = self.media_obj.run_time_seconds run_time_secs = self.media_obj.run_time_seconds
percent = int((playback_seconds / run_time_secs) * 100) percent = int((playback_seconds / run_time_secs) * 100)
@ -917,6 +984,16 @@ class Scrobble(TimeStampedModel):
) )
return True return True
@classmethod
def by_date(cls, media_type: str = "Track"):
cls.objects.filter(media_type=media_type).values(
"timestamp__date"
).annotate(count=models.Count("id")).values(
"timestamp__date", "count"
).order_by(
"-count",
)
@property @property
def media_obj(self): def media_obj(self):
media_obj = None media_obj = None
@ -942,8 +1019,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.life_event media_obj = self.life_event
if self.mood: if self.mood:
media_obj = self.mood media_obj = self.mood
if self.brickset: if self.brick_set:
media_obj = self.brickset media_obj = self.brick_set
if self.trail: if self.trail:
media_obj = self.trail media_obj = self.trail
if self.beer: if self.beer:

View File

@ -8,6 +8,7 @@ import pytz
from beers.models import Beer from beers.models import Beer
from boardgames.models import BoardGame from boardgames.models import BoardGame
from books.models import Book from books.models import Book
from bricksets.models import BrickSet
from dateutil.parser import parse from dateutil.parser import parse
from django.utils import timezone from django.utils import timezone
from locations.constants import LOCATION_PROVIDERS from locations.constants import LOCATION_PROVIDERS
@ -23,7 +24,7 @@ from scrobbles.constants import (
SCROBBLE_CONTENT_URLS, SCROBBLE_CONTENT_URLS,
) )
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.utils import convert_to_seconds from scrobbles.utils import convert_to_seconds, extract_domain
from sports.models import SportEvent from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb from sports.thesportsdb import lookup_event_from_thesportsdb
from tasks.models import Task from tasks.models import Task
@ -322,14 +323,12 @@ def manual_scrobble_from_url(
we know about the content type, and routes it to the appropriate media we know about the content type, and routes it to the appropriate media
scrobbler. Otherwise, return nothing.""" scrobbler. Otherwise, return nothing."""
content_key = "" content_key = ""
try: domain = extract_domain(url)
domain = url.split("//")[-1].split("/")[0]
except IndexError:
domain = None
for key, content_url in SCROBBLE_CONTENT_URLS.items(): for key, content_urls in SCROBBLE_CONTENT_URLS.items():
if domain in content_url: for content_url in content_urls:
content_key = key if domain in content_url:
content_key = key
item_id = None item_id = None
if not content_key: if not content_key:
@ -343,8 +342,10 @@ def manual_scrobble_from_url(
except IndexError: except IndexError:
pass pass
if content_key == "-i": if content_key == "-i" and "v=" in url:
item_id = url.split("v=")[1].split("&")[0] item_id = url.split("v=")[1].split("&")[0]
elif content_key == "-i" and "title/tt" in url:
item_id = "tt" + str(item_id)
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key] scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
return eval(scrobble_fn)(item_id, user_id, action=action) return eval(scrobble_fn)(item_id, user_id, action=action)
@ -500,6 +501,151 @@ def todoist_scrobble_task(
return scrobble return scrobble
def emacs_scrobble_update_task(
emacs_id: str, emacs_notes: dict, user_id: int
) -> Optional[Scrobble]:
scrobble = Scrobble.objects.filter(
in_progress=True,
user_id=user_id,
log__source_id=emacs_id,
log__source="emacs",
).first()
if not scrobble:
logger.info(
"[emacs_scrobble_update_task] no task found",
extra={
"emacs_notes": emacs_notes,
"user_id": user_id,
"media_type": Scrobble.MediaType.TASK,
},
)
return
notes_updated = False
for note in emacs_notes:
existing_note_ts = [
n.get("timestamp") for n in scrobble.log.get("notes", [])
]
if not scrobble.log.get('notes"'):
scrobble.log["notes"] = []
if note.get("timestamp") not in existing_note_ts:
scrobble.log["notes"].append(
{note.get("timestamp"): note.get("content")}
)
notes_updated = True
if notes_updated:
scrobble.save(update_fields=["log"])
logger.info(
"[emacs_scrobble_update_task] emacs note added",
extra={
"emacs_note": emacs_notes,
"user_id": user_id,
"media_type": Scrobble.MediaType.TASK,
},
)
return scrobble
def emacs_scrobble_task(
task_data: dict,
user_id: int,
started: bool = False,
stopped: bool = False,
) -> Scrobble | None:
prefix = ""
suffix = ""
source_id = task_data.get("source_id")
for label in task_data.get("labels"):
if label in TODOIST_TITLE_PREFIX_LABELS:
prefix = label
if label in TODOIST_TITLE_SUFFIX_LABELS:
suffix = label
if not prefix and not suffix:
logger.warning(
"Missing a prefix and suffix tag for task",
extra={"emacs_scrobble_task": task_data},
)
title = " ".join([prefix.capitalize(), suffix.capitalize()])
task = Task.find_or_create(title)
timestamp = pendulum.parse(task_data.get("updated_at", timezone.now()))
in_progress_scrobble = Scrobble.objects.filter(
user_id=user_id,
in_progress=True,
log__source_id=source_id,
log__source="orgmode",
task=task,
).last()
if not in_progress_scrobble and stopped:
logger.info(
"[emacs_scrobble_task] cannot stop already stopped task",
extra={
"emacs_id": source_id,
},
)
return
if in_progress_scrobble and started:
logger.info(
"[emacs_scrobble_task] cannot start already started task",
extra={
"emacs_id": source_id,
},
)
return in_progress_scrobble
# Finish an in-progress scrobble
if in_progress_scrobble and stopped:
logger.info(
"[emacs_scrobble_task] finishing",
extra={
"emacs_id": source_id,
},
)
in_progress_scrobble.stop(timestamp=timestamp, force_finish=True)
return in_progress_scrobble
if in_progress_scrobble:
return in_progress_scrobble
notes = task_data.pop("notes")
if notes:
task_data["notes"] = []
for note in notes:
task_data["notes"].append(
{note.get("timestamp"): note.get("content")}
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timestamp,
"playback_position_seconds": 0,
"source": "Org-mode",
"log": task_data,
}
logger.info(
"[emacs_scrobble_task] creating",
extra={
"task_id": task.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.TASK,
},
)
scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
return scrobble
def manual_scrobble_task(url: str, user_id: int, action: Optional[str] = None): def manual_scrobble_task(url: str, user_id: int, action: Optional[str] = None):
source_id = re.findall(r"\d+", url)[0] source_id = re.findall(r"\d+", url)[0]
@ -708,3 +854,34 @@ def manual_scrobble_puzzle(
# TODO Kick out a process to enrich the media here, and in every scrobble event # TODO Kick out a process to enrich the media here, and in every scrobble event
return Scrobble.create_or_update(puzzle, user_id, scrobble_dict) return Scrobble.create_or_update(puzzle, user_id, scrobble_dict)
def manual_scrobble_brickset(
brickset_id: str, user_id: int, action: Optional[str] = None
):
brickset = BrickSet.find_or_create(brickset_id)
if not brickset:
logger.error(f"No brickset found for Brickset ID {brickset_id}")
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Vrobbler",
"log": {"serial_scrobble_id": ""},
}
logger.info(
"[vrobbler-scrobble] brickset scrobble request received",
extra={
"brickset_id": brickset.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.BRICKSET,
},
)
# TODO Kick out a process to enrich the media here, and in every scrobble event
# TODO Need to check for past scrobbles and auto populate serial scrobble id if possible
return Scrobble.create_or_update(brickset, user_id, scrobble_dict)

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from scrobbles import views from scrobbles import views
from tasks.webhooks import todoist_webhook from tasks.webhooks import todoist_webhook, emacs_webhook
app_name = "scrobbles" app_name = "scrobbles"
@ -52,6 +52,13 @@ urlpatterns = [
name="mopidy-webhook", name="mopidy-webhook",
), ),
path("webhook/todoist/", todoist_webhook, name="todoist-webhook"), path("webhook/todoist/", todoist_webhook, name="todoist-webhook"),
path("webhook/emacs/", emacs_webhook, name="emacs_webhook"),
path("export/", views.export, name="export"),
path(
"imports/",
views.ScrobbleImportListView.as_view(),
name="import-detail",
),
path("export/", views.export, name="export"), path("export/", views.export, name="export"),
path( path(
"imports/", "imports/",

View File

@ -3,6 +3,7 @@ import logging
import re import re
from datetime import datetime, timedelta, tzinfo from datetime import datetime, timedelta, tzinfo
from sqlite3 import IntegrityError from sqlite3 import IntegrityError
from urllib.parse import urlparse
import pytz import pytz
from django.apps import apps from django.apps import apps
@ -17,9 +18,10 @@ from scrobbles.tasks import (
process_lastfm_import, process_lastfm_import,
process_retroarch_import, process_retroarch_import,
) )
from vrobbler.apps.scrobbles.notifications import NtfyNotification
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
from vrobbler.apps.scrobbles.notifications import NtfyNotification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -67,7 +69,8 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
return Scrobble.objects.filter(media_query, user=user) return Scrobble.objects.filter(media_query, user=user)
def get_recently_played_board_games(user: User) -> dict: ... def get_recently_played_board_games(user: User) -> dict:
...
def get_long_plays_in_progress(user: User) -> dict: def get_long_plays_in_progress(user: User) -> dict:
@ -187,8 +190,8 @@ def delete_zombie_scrobbles(dry_run=True):
def import_from_webdav_for_all_users(restart=False): def import_from_webdav_for_all_users(restart=False):
"""Grab a list of all users with WebDAV enabled and kickoff imports for them""" """Grab a list of all users with WebDAV enabled and kickoff imports for them"""
from scrobbles.models import KoReaderImport
from books.koreader import fetch_file_from_webdav from books.koreader import fetch_file_from_webdav
from scrobbles.models import KoReaderImport
# LastFmImport = apps.get_model("scrobbles", "LastFMImport") # LastFmImport = apps.get_model("scrobbles", "LastFMImport")
webdav_enabled_user_ids = UserProfile.objects.filter( webdav_enabled_user_ids = UserProfile.objects.filter(
@ -271,6 +274,7 @@ def get_file_md5_hash(file_path: str) -> str:
file_hash.update(chunk) file_hash.update(chunk)
return file_hash.hexdigest() return file_hash.hexdigest()
def deduplicate_tracks(commit=False) -> int: def deduplicate_tracks(commit=False) -> int:
from music.models import Track from music.models import Track
@ -293,7 +297,11 @@ def deduplicate_tracks(commit=False) -> int:
try: try:
other.delete() other.delete()
except IntegrityError as e: except IntegrityError as e:
print("could not delete ", other.id, f": IntegrityError {e}") print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(dups) return len(dups)
@ -318,3 +326,13 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
notifications_sent += 1 notifications_sent += 1
return notifications_sent return notifications_sent
def extract_domain(url):
parsed_url = urlparse(url)
domain = (
parsed_url.netloc.split(".")[-2]
+ "."
+ parsed_url.netloc.split(".")[-1]
)
return domain

View File

@ -2,10 +2,11 @@ import calendar
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import pendulum
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Q
@ -52,7 +53,6 @@ from scrobbles.tasks import (
from scrobbles.utils import ( from scrobbles.utils import (
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, get_long_plays_in_progress,
get_recently_played_board_games,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -107,32 +107,94 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
user = self.request.user user = self.request.user
data["date"] = ""
if user.is_authenticated: if user.is_authenticated:
self.queryset = self.get_queryset().filter(user=user)
user_id = self.request.user.id
completed_for_user = Scrobble.objects.filter( today = timezone.localtime(timezone.now())
played_to_completion=True, user=user date_str = self.request.GET.get("date", "")
) date = today
data["long_play_in_progress"] = get_long_plays_in_progress(user) if date_str:
data["play_again"] = get_recently_played_board_games(user) try:
data["video_scrobble_list"] = completed_for_user.filter( date = pendulum.parse(date_str)
video__isnull=False except:
).order_by("-timestamp")[:15] pass
if date_str:
data["podcast_scrobble_list"] = completed_for_user.filter( if date_str == "this_week" or "-W" in date_str:
podcast_episode__isnull=False next_date = date + timedelta(weeks=+2)
).order_by("-timestamp")[:15] prev_date = date + timedelta(weeks=-1)
if date.isocalendar()[1] < today.isocalendar()[1]:
data["sport_scrobble_list"] = completed_for_user.filter( data[
sport_event__isnull=False "next_link"
).order_by("-timestamp")[:15] ] = f"?date={next_date.strftime('%Y-W%W')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-W%W')}"
data["videogame_scrobble_list"] = completed_for_user.filter( data[
video_game__isnull=False "title"
).order_by("-timestamp")[:15] ] = f"Week {date.strftime('%-W')} of {date.year}"
data = data | Scrobble.as_dict_by_type(
data["boardgame_scrobble_list"] = completed_for_user.filter( Scrobble.for_week(
board_game__isnull=False user_id, date.year, date.isocalendar()[1]
).order_by("-timestamp")[:15] )
)
elif date_str == "this_month" or date_str.count("-") == 1:
next_date = date + relativedelta(months=1)
prev_date = date + relativedelta(months=-1)
if date.month < today.month:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m')}"
data["title"] = f"{date.strftime('%B %Y')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_month(user_id, date.year, date.month)
)
elif date_str == "today" or date_str.count("-") == 2:
next_date = date + timedelta(days=1)
prev_date = date - timedelta(days=1)
data[
"prev_link"
] = f"?date={prev_date.strftime('%Y-%m-%d')}"
if date < today:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m-%d')}"
if date == today:
data["title"] = "Today"
else:
data["title"] = f"{date.strftime('%Y-%m-%d')}"
data[
"today_link"
] = f"?date={today.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(
user_id, date.year, date.month, date.day
)
)
elif date_str == "this_year" or date_str.count("-") == 0:
next_date = date + relativedelta(years=+1)
prev_date = date + relativedelta(years=-1)
data["next_link"] = ""
if date.year < today.year:
data["title"] = f"{date.strftime('%Y')}"
data["next_link"] = f"?date={next_date.year}"
data["title"] = f"{date.year}"
data["prev_link"] = f"?date={prev_date.year}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_year(user_id, date.year)
)
else:
next_date = today - timedelta(days=1)
prev_date = today - timedelta(days=1)
data["title"] = "Today"
data["next_link"] = ""
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(user_id, date.year, date.month, date.day)
)
data[
"today_link"
] = "" # f"?date={today.strftime('%Y-%m-%d')}"
data["active_imports"] = AudioScrobblerTSVImport.objects.filter( data["active_imports"] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False, processing_started__isnull=False,
@ -149,9 +211,7 @@ class RecentScrobbleList(ListView):
return data return data
def get_queryset(self): def get_queryset(self):
return Scrobble.objects.filter( return Scrobble.objects.all().order_by("-timestamp")
track__isnull=False, in_progress=False
).order_by("-timestamp")[:15]
class ScrobbleLongPlaysView(TemplateView): class ScrobbleLongPlaysView(TemplateView):
@ -698,45 +758,13 @@ class ChartRecordView(TemplateView):
media_type = self.request.GET.get("media", "Track") media_type = self.request.GET.get("media", "Track")
user = self.request.user user = self.request.user
params = {} params = {}
context_data["chart_type"] = self.request.GET.get(
"chart_type", "maloja"
)
context_data["artist_charts"] = {} context_data["artist_charts"] = {}
if not date: if not date:
limit = 20 limit = 20
artist_params = {"user": user, "media_type": "Artist"}
context_data["current_artist_charts"] = {
"today": live_charts(
**artist_params, chart_period="today", limit=limit
),
"week": live_charts(
**artist_params, chart_period="week", limit=limit
),
"month": live_charts(
**artist_params, chart_period="month", limit=limit
),
"year": live_charts(
**artist_params, chart_period="year", limit=limit
),
"all": live_charts(**artist_params, limit=limit),
}
track_params = {"user": user, "media_type": "Track"}
context_data["current_track_charts"] = {
"today": live_charts(
**track_params, chart_period="today", limit=limit
),
"week": live_charts(
**track_params, chart_period="week", limit=limit
),
"month": live_charts(
**track_params, chart_period="month", limit=limit
),
"year": live_charts(
**track_params, chart_period="year", limit=limit
),
"all": live_charts(**track_params, limit=limit),
}
limit = 14
artist = {"user": user, "media_type": "Artist", "limit": limit} artist = {"user": user, "media_type": "Artist", "limit": limit}
# This is weird. They don't display properly as QuerySets, so we cast to lists # This is weird. They don't display properly as QuerySets, so we cast to lists
context_data["chart_keys"] = { context_data["chart_keys"] = {

View File

@ -50,7 +50,8 @@ class Task(LongPlayScrobblableMixin):
url = "" url = ""
scrobble = self.scrobbles(user_id).first() scrobble = self.scrobbles(user_id).first()
if scrobble: if scrobble:
url = TODOIST_TASK_URL.format(id=scrobble.logdata.todoist_id) if scrobble.log.get("source") == "todoist":
url = TODOIST_TASK_URL.format(id=scrobble.logdata.todist_id)
return url return url
def subtitle_for_user(self, user_id): def subtitle_for_user(self, user_id):

View File

@ -1,16 +1,18 @@
import json
import logging import logging
import pendulum
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from profiles.models import UserProfile
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from scrobbles.scrobblers import ( from scrobbles.scrobblers import (
emacs_scrobble_task,
emacs_scrobble_update_task,
todoist_scrobble_task, todoist_scrobble_task,
todoist_scrobble_update_task, todoist_scrobble_update_task,
) )
from profiles.models import UserProfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,7 +31,7 @@ def todoist_webhook(request):
todoist_type, todoist_event = post_data.get("event_name").split(":") todoist_type, todoist_event = post_data.get("event_name").split(":")
event_data = post_data.get("event_data", {}) event_data = post_data.get("event_data", {})
is_item_type = todoist_type == "item" is_item_type = todoist_type == "item"
is_note_type = todoist_type == "note" is_note_type = todoist_tyllll = "note"
new_labels = event_data.get("labels", []) new_labels = event_data.get("labels", [])
old_labels = ( old_labels = (
post_data.get("event_data_extra", {}) post_data.get("event_data_extra", {})
@ -118,3 +120,54 @@ def todoist_webhook(request):
extra={"scrobble_id": scrobble.id}, extra={"scrobble_id": scrobble.id},
) )
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK) return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
def emacs_webhook(request):
try:
post_data = json.loads(request.data)
except TypeError:
post_data = request.data
logger.info(
"[emacs_webhook] called",
extra={"post_data": post_data},
)
task_in_progress = post_data.get("state") == "STRT"
task_stopped = post_data.get("state") == "DONE"
post_data["source_id"] = post_data.pop("emacs_id")
# TODO: Figure out why token auth is not working
user_id = request.user.id
if not user_id:
user_id = 1
scrobble = None
if post_data.get("source_id"):
scrobble = emacs_scrobble_task(
post_data, user_id, started=task_in_progress, stopped=task_stopped
)
if scrobble and task_in_progress and post_data.get("notes"):
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes"),
user_id,
)
if not scrobble:
logger.info(
"[emacs_webhook] finished with no note or task found",
extra={"scrobble_id": None},
)
return Response(
{"error": "No scrobble found to be updated"},
status=status.HTTP_304_NOT_MODIFIED,
)
logger.info(
"[emacs_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)

View File

@ -57,6 +57,8 @@ class VideoMetadata:
def as_dict_with_cover_and_genres(self) -> tuple: def as_dict_with_cover_and_genres(self) -> tuple:
video_dict = vars(self) video_dict = vars(self)
cover = video_dict.pop("cover_url") cover = None
if "cover_url" in video_dict.keys():
cover = video_dict.pop("cover_url")
genres = video_dict.pop("genres") genres = video_dict.pop("genres")
return video_dict, cover, genres return video_dict, cover, genres

View File

@ -163,6 +163,7 @@ SITE_ID = 1
MIDDLEWARE = [ MIDDLEWARE = [
"vrobbler.health_check.HealthCheckMiddleware", "vrobbler.health_check.HealthCheckMiddleware",
"vrobbler.apps.scrobbles.middleware.TimezoneMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",

View File

@ -1,212 +1,90 @@
{% load humanize %} {% load humanize %}
{% load naturalduration %} {% load naturalduration %}
<div>
<h2>Last Scrobbles</h2>
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div>
<div class="row"> <div class="row">
<ul class="nav nav-tabs" id="myTab" role="tablist"> <div>
<li class="nav-item" role="presentation"> <p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" </div>
type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button> </div>
</li> <div class="row">
<li class="nav-item" role="presentation"> <div class="col-md">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" {% if Track %}
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button> <h2>Music</h2>
</li> {% with scrobbles=Track count=Track_count time=Track_time %}
<li class="nav-item" role="presentation"> {% include "scrobbles/_scrobble_table.html" %}
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" {% endwith %}
type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button> {% endif %}
</li> </div>
<li class="nav-item" role="presentation"> <div class="col-md">
<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> {% if Task %}
</li> <h2>Latest tasks</h2>
<li class="nav-item" role="presentation"> {% with scrobbles=Task count=Task_count time=Task_time %}
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-videogames" {% include "scrobbles/_scrobble_table.html" %}
type="button" role="tab" aria-controls="profile" aria-selected="false">Video Games</button> {% endwith %}
</li> {% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-boardgames" {% if Video %}
type="button" role="tab" aria-controls="profile" aria-selected="false">Board Games</button> <h2>Videos</h2>
</li> {% with scrobbles=Video count=Video_count time=Video_time %}
</ul> {% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
<div class="tab-content" id="myTabContent2"> {% endif %}
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
aria-labelledby="latest-listened-tab"> {% if WebPage %}
<div class="table-responsive"> <h4>Web pages</h4>
<table class="table table-striped table-sm"> {% with scrobbles=WebPage count=WebPage_count time=WebPage_time %}
<thead> {% include "scrobbles/_scrobble_table.html" %}
<tr> {% endwith %}
<th scope="col">Time</th> {% endif %}
<th scope="col">Album</th>
<th scope="col">Track</th> {% if SportEvent %}
<th scope="col">Artist</th> <h2>Sports</h2>
</tr> {% with scrobbles=SportEvent count=SportEvent_count time=SportEvent_time %}
</thead> {% include "scrobbles/_scrobble_table.html" %}
<tbody> {% endwith %}
{% for scrobble in object_list %} {% endif %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td> {% if PodcastEpisode %}
{% if scrobble.track.album.cover_image %} <h2>Latest podcasts</h2>
<td><a href="{{scrobble.track.album.get_absolute_url}}"><img src="{{scrobble.track.album.cover_image_small.url}}" width=25 height=25 style="border:1px solid black;" /></aa></td> {% with scrobbles=PodcastEpisode count=PodcastEpisode_count time=PodcastEpisode_time %}
{% else %} {% include "scrobbles/_scrobble_table.html" %}
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td> {% endwith %}
{% endif %} {% 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> {% if VideoGame %}
</tr> <h4>Video games</h4>
{% endfor %} {% with scrobbles=VideoGame count=VideoGame_count time=VideoGame_time %}
</tbody> {% include "scrobbles/_scrobble_table.html" %}
</table> {% endwith %}
</div> {% endif %}
</div>
{% if BoardGame %}
<div class="tab-pane fade show" id="latest-watched" role="tabpanel" <h4>Board games</h4>
aria-labelledby="latest-watched-tab"> {% with scrobbles=BoardGame count=BoardGame_count time=BoardGame_time %}
<h2>Latest watched</h2> {% include "scrobbles/_scrobble_table.html" %}
<div class="table-responsive"> {% endwith %}
<table class="table table-striped table-sm"> {% endif %}
<thead>
<tr> {% if Beer %}
<th scope="col">Time</th> <h4>Beers</h4>
<th scope="col">Cover</th> {% with scrobbles=Beer count=Beer_count time=Beer_time %}
<th scope="col">Title</th> {% include "scrobbles/_scrobble_table.html" %}
<th scope="col">Series</th> {% endwith %}
</tr> {% endif %}
</thead>
<tbody> {% if BrickSet %}
{% for scrobble in video_scrobble_list %} <h4>Brick sets</h4>
<tr> {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}
<td>{{scrobble.timestamp|naturaltime}}</td> {% include "scrobbles/_scrobble_table.html" %}
{% if scrobble.video.cover_image %} {% endwith %}
<td><img src="{{scrobble.media_obj.cover_image_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td> {% endif %}
{% else %}
<td></td> {% if Book %}
{% endif %} <h4>Books</h4>
<td><a href="{{scrobble.video.get_absolute_url }}">{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</a></td> {% with scrobbles=Book count=Book_count time=Book_time %}
<td><a href="{{scrobble.video.tv_series.get_absolute_url }}">{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}</a>{% endif %} {% include "scrobbles/_scrobble_table.html" %}
</td> {% endwith %}
</tr> {% endif %}
{% 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">Round</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.round.name}}</td>
<td>{{scrobble.sport_event.round.season.league}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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 class="tab-pane fade show" id="latest-videogames" role="tabpanel"
aria-labelledby="latest-videogames-tab">
<h2>Latest Video Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Cover/Screenshot</th>
<th scope="col">Title</th>
<th scope="col">Time played (mins)</th>
<th scope="col">Percent complete</th>
</tr>
</thead>
<tbody>
{% for scrobble in videogame_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.screenshot %}
<td><img src="{{scrobble.screenshot_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
{% else %}
{% if scrobble.media_obj.hltb_cover %}
<td><img src="{{scrobble.media_obj.hltb_cover_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
{% endif %}
{% endif %}
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
<td>{{scrobble.playback_position_seconds|natural_duration}}</td>
<td>{{scrobble.percent_played}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-boardgames" role="tabpanel"
aria-labelledby="latest-boardgames-tab">
<h2>Latest Board Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Cover</th>
<th scope="col">Title</th>
<th scope="col">Time played (mins)</th>
</tr>
</thead>
<tbody>
{% for scrobble in boardgame_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td><img src="{{scrobble.media_obj.cover_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
<td>{{scrobble.playback_position_seconds|natural_duration}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,7 @@
{% load humanize %}
{% load naturalduration %}
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
<td>{% if scrobble.in_progress %}{{scrobble.media_obj.strings.verb}} now | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}{{scrobble.timestamp|naturaltime}}{% endif %}</td>
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj|truncatechars_html:45}}</a></td>
<td>{{scrobble.elapsed_time|natural_duration}}</td>
</tr>

View File

@ -0,0 +1,23 @@
{% load humanize %}
{% load naturalduration %}
<div class="tab-pane fade show" id="latest-beers" role="tabpanel"
aria-labelledby="latest-beers-tab">
<div class="table-responsive">
{{count}} scrobble {% if time %}| {{time|natural_duration}}{% endif %}
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles %}
{% include "scrobbles/_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@ -1,173 +1,176 @@
{% load static %} {% load static %}
<h2>Top Artist</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}"
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="artistTabContent" class="maloja-chart">
{% for key, artists in current_artist_charts.items %} <div class="row">
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel" aria-labelledby="artist-{{key}}-tab"> <h2>Top Artist</h2>
<div style="display:block"> <ul class="nav nav-tabs" id="artistTab" role="tablist">
<div style="float:left;"> {% for key, name in chart_keys.items %}
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0"> <li class="nav-item" role="presentation">
<div class="caption">#1 {{artists.0.name}}</div> <button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
{% if artists.0 %} id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}"
{% if artists.0.thumbnail %} type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button>
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{{artists.0.thumbnail_medium.url}}" width="300px"></a> </li>
{% else %} {% endfor %}
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a> </ul>
{% endif %}
{% endif %} <div class="tab-content" id="artistTabContent" class="maloja-chart">
</div> {% for key, artists in current_artist_charts.items %}
</div> <div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel" aria-labelledby="artist-{{key}}-tab">
<div style="float:left; width:300px;"> <div style="display:block">
<div style="display:flex; flex-wrap: wrap;"> <div style="float:left;">
<div class="image-wrapper" class="image-wrapper" style="width:50%"> <div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption-medium">#2 {{artists.1.name}}</div> <div class="caption">#1 {{artists.0.name}}</div>
{% if artists.1 %} {% if artists.0 %}
{% if artists.1.thumbnail %} {% if artists.0.thumbnail %}
<a href="{{artists.1.get_absolute_url}}"><img lt="{{artists.1.name}}" src="{{artists.1.thumbnail_medium.url}}" width="150px"></a> <a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{{artists.0.thumbnail_medium.url}}" width="300px"></a>
{% else %} {% else %}
<a href="{{artists.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a> <a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{artists.2.name}}</div>
{% if artists.2 %}
{% if artists.2.thumbnail %}
<a href="{{artists.2.get_absolute_url}}"><img src="{{artists.2.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{artists.3.name}}</div>
{% if artists.3 %}
{% if artists.3.thumbnail %}
<a href="{{artists.3.get_absolute_url}}"><img src="{{artists.3.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{artists.4.name}}</div>
{% if artists.4 %}
{% if artists.4.thumbnail %}
<a href="{{artists.4.get_absolute_url}}"><img src="{{artists.4.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> <div style="float:left; width:300px;">
<div style="float:left; width:300px;"> <div style="display:flex; flex-wrap: wrap;">
<div style="display:flex; flex-wrap: wrap;"> <div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="image-wrapper" class="image-wrapper" class="image-wrapper" style="width:33;"> <div class="caption-medium">#2 {{artists.1.name}}</div>
<div class="caption-small">#6 {{artists.5.name}}</div> {% if artists.1 %}
{% if artists.5 %} {% if artists.1.thumbnail %}
{% if artists.5.thumbnail %} <a href="{{artists.1.get_absolute_url}}"><img lt="{{artists.1.name}}" src="{{artists.1.thumbnail_medium.url}}" width="150px"></a>
<a href="{{artists.5.get_absolute_url}}"><img src="{{artists.5.thumbnail_medium.url}}" width="100px"></a> {% else %}
{% else %} <a href="{{artists.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
<a href="{{artists.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% endif %}
{% endif %} {% endif %}
{% endif %} </div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{artists.2.name}}</div>
{% if artists.2 %}
{% if artists.2.thumbnail %}
<a href="{{artists.2.get_absolute_url}}"><img src="{{artists.2.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{artists.3.name}}</div>
{% if artists.3 %}
{% if artists.3.thumbnail %}
<a href="{{artists.3.get_absolute_url}}"><img src="{{artists.3.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{artists.4.name}}</div>
{% if artists.4 %}
{% if artists.4.thumbnail %}
<a href="{{artists.4.get_absolute_url}}"><img src="{{artists.4.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
</div> </div>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> </div>
<div class="caption-small">#7 {{artists.6.name}}</div> <div style="float:left; width:300px;">
{% if artists.6 %} <div style="display:flex; flex-wrap: wrap;">
{% if artists.6.thumbnail %} <div class="image-wrapper" class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.6.get_absolute_url}}"><img src="{{artists.6.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#6 {{artists.5.name}}</div>
{% else %} {% if artists.5 %}
<a href="{{artists.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.5.thumbnail %}
{% endif %} <a href="{{artists.5.get_absolute_url}}"><img src="{{artists.5.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#8 {{artists.7.name}}</div> {% endif %}
{% if artists.7 %} </div>
{% if artists.7.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.7.get_absolute_url}}"><img src="{{artists.7.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#7 {{artists.6.name}}</div>
{% else %} {% if artists.6 %}
<a href="{{artists.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.6.thumbnail %}
{% endif %} <a href="{{artists.6.get_absolute_url}}"><img src="{{artists.6.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#9 {{artists.8.name}}</div> {% endif %}
{% if artists.8 %} </div>
{% if artists.8.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.8.get_absolute_url}}"><img src="{{artists.8.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#8 {{artists.7.name}}</div>
{% else %} {% if artists.7 %}
<a href="{{artists.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.7.thumbnail %}
{% endif %} <a href="{{artists.7.get_absolute_url}}"><img src="{{artists.7.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#10 {{artists.9.name}}</div> {% endif %}
{% if artists.9 %} </div>
{% if artists.9.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.9.get_absolute_url}}"><img src="{{artists.9.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#9 {{artists.8.name}}</div>
{% else %} {% if artists.8 %}
<a href="{{artists.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.8.thumbnail %}
{% endif %} <a href="{{artists.8.get_absolute_url}}"><img src="{{artists.8.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#11 {{artists.10.name}}</div> {% endif %}
{% if artists.10 %} </div>
{% if artists.10.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.10.get_absolute_url}}"><img src="{{artists.10.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#10 {{artists.9.name}}</div>
{% else %} {% if artists.9 %}
<a href="{{artists.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.9.thumbnail %}
{% endif %} <a href="{{artists.9.get_absolute_url}}"><img src="{{artists.9.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#12 {{artists.11.name}}</div> {% endif %}
{% if artists.11 %} </div>
{% if artists.11.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.11.get_absolute_url}}"><img src="{{artists.11.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#11 {{artists.10.name}}</div>
{% else %} {% if artists.10 %}
<a href="{{artists.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.10.thumbnail %}
{% endif %} <a href="{{artists.10.get_absolute_url}}"><img src="{{artists.10.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#13 {{artists.12.name}}</div> {% endif %}
{% if artists.12 %} </div>
{% if artists.12.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.12.get_absolute_url}}"><img src="{{artists.12.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#12 {{artists.11.name}}</div>
{% else %} {% if artists.11 %}
<a href="{{artists.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.11.thumbnail %}
{% endif %} <a href="{{artists.11.get_absolute_url}}"><img src="{{artists.11.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
</div> <a href="{{artists.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% endif %}
<div class="caption-small">#14 {{artists.13.name}}</div> {% endif %}
{% if artists.13 %} </div>
{% if artists.13.thumbnail %} <div class="image-wrapper" class="image-wrapper" style="width:33;">
<a href="{{artists.13.get_absolute_url}}"><img src="{{artists.13.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#13 {{artists.12.name}}</div>
{% else %} {% if artists.12 %}
<a href="{{artists.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> {% if artists.12.thumbnail %}
{% endif %} <a href="{{artists.12.get_absolute_url}}"><img src="{{artists.12.thumbnail_medium.url}}" width="100px"></a>
{% endif %} {% else %}
<a href="{{artists.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#14 {{artists.13.name}}</div>
{% if artists.13 %}
{% if artists.13.thumbnail %}
<a href="{{artists.13.get_absolute_url}}"><img src="{{artists.13.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endfor %}
</div>
</div> </div>
<div class="row"> <div class="row">

View File

@ -2,12 +2,54 @@
{% block title %}{{name}}{% endblock %} {% block title %}{{name}}{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom:100px; }
h2 { padding-top:20px; }
.image-wrapper {
contain: content;
}
.image-wrapper :hover {
background:rgba(0,0,0,0.3);
}
.caption {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 90%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 75%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-small {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 60%;
color:white;
background:rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %} {% block lists %}
<div "calss="row> {% if chart_type == "maloja" %}
{% include "scrobbles/_top_charts.html" %} {% include "scrobbles/_top_charts.html" %}
</div> {% else %}
<div class="row"> <div class="row">
{% if artist_charts %} {% if artist_charts %}
<div class="col-md"> <div class="col-md">
@ -151,5 +193,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -43,15 +43,38 @@
background:rgba(0,0,0,0.4); background:rgba(0,0,0,0.4);
} }
.in-progress {
background: #CCFFFF;
}
.in-progress a { color: black }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4"> <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div <div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> 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> <h1 class="h2">{% if date %}{{date|naturaltime}}{% else %}{{title}}{% endif %}</h1>
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
<button type="submit" class="btn btn-sm btn-outline-secondary">Last.fm Sync</button>
</form>
{% endif %}
{% if prev_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{prev_link}}"
data-bs-target="#">Previous</a>
{% endif %}
{% if today_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{today_link}}"
data-bs-target="#">Today</a>
{% endif %}
{% if next_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{next_link}}"
data-bs-target="#">Next</a>
{% endif %}
</div>
<div class="btn-group me-2"> <div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} {% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
@ -69,11 +92,13 @@
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton"
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div data-feather="calendar"></div> <div data-feather="calendar"></div>
This week {{title}}
</button> </button>
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton"> <div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
<a class="dropdown-item" href="#">This month</a> <a class="dropdown-item" href="?date=today">Today</a>
<a class="dropdown-item" href="#">This year</a> <a class="dropdown-item" href="?date=this_week">This week</a>
<a class="dropdown-item" href="?date=this_month">This month</a>
<a class="dropdown-item" href="?date=this_year">This year</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -62,6 +62,7 @@
<td>{{scrobble.timestamp}}</td> <td>{{scrobble.timestamp}}</td>
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td> <td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
<td>{{scrobble.source}}</td> <td>{{scrobble.source}}</td>
<td>{{scrobble.log.notes}}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -1,5 +1,8 @@
<html> <html>
<style>label { display:none; } textarea {height:8em; width:100%}</style> <head>
<style>label { display:none; } textarea {height:8em; width:100%}</style>
<title>Reading {{object.title}}</title>
</head>
<body> <body>
<form method=post>{% csrf_token %} <form method=post>{% csrf_token %}
{{form.as_p}} {{form.as_p}}
@ -7,7 +10,7 @@
<input type="submit" value="Finish" /> <input type="submit" value="Finish" />
</form> </form>
<a href="{{object.url}}" target="_blank">Open in new window</a> <a href="{{object.url}}" target="_blank">Open in new window</a>
<iframe style="height:78%; width:100%" src="{{object.url}}" title="{{object.url}}" allowfullscreen sandbox> <iframe style="height:84%; width:100%" src="{{object.url}}" title="{{object.url}}" allowfullscreen sandbox>
<p> <p>
Page could not be opened, use link above to read. Page could not be opened, use link above to read.
</p> </p>