Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0671ab432f | |||
| 893867419a | |||
| d9dfec81aa | |||
| 948fbc19bf | |||
| 7d708ad8a6 | |||
| e0505cb82c | |||
| ab6459e4b0 |
25
PROJECT.org
25
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/23] :vrobbler:project:personal:
|
||||
* Backlog [0/22] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -604,7 +604,28 @@ independent of the email flow it was originally creatdd for
|
||||
|
||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||
|
||||
** TODO [#C] What would it look like to add an MCP server to expose scrobbles and media items? :mcpserver:feature:
|
||||
* Version 57.0 [5/5]
|
||||
** DONE [#A] Scrobble button on some media list pages dont work :bug:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: a3a5c707-2e3d-a6b1-0f7f-4c6f7433aa1f
|
||||
:END:
|
||||
** DONE [#B] Use HTMx to update the Now Playing widget :feature:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 5f5631fc-9ee1-d5a5-d0f8-94fea6fbbfa4
|
||||
:END:
|
||||
** DONE [#B] Add a live page that updates the scrobble list via JS polling :feature:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 58790d76-dc6e-8aa5-2dc0-e64fe786fbf1
|
||||
:END:
|
||||
** DONE [#A] Turns out we cant cache the now playing widget :bug:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 9ce669ea-c000-cdfe-a634-ad5cdaeae81c
|
||||
:END:
|
||||
** DONE [#C] What would it look like to add an MCP server to expose scrobbles and media items? :mcpserver:feature:
|
||||
:PROPERTIES:
|
||||
:ID: c5fca159-c7e0-5795-7c05-bbc48f539650
|
||||
:END:
|
||||
|
||||
* Version 56.4 [3/3]
|
||||
** DONE [#B] Add ability to do reverse address lookup on lat-long pairs :geolocations:feature:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "56.4"
|
||||
version = "57.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -9,7 +9,7 @@ python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = "^0.20.0"
|
||||
python-dotenv = ">=0.20.0,<2"
|
||||
python-json-logger = "^2.0.2"
|
||||
colorlog = "^6.6.0"
|
||||
httpx = "<=0.27.2"
|
||||
@ -43,6 +43,7 @@ ipython = "^8.14.0"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
django-mcp-server = "^0.5.7"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
|
||||
@ -40,6 +40,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"enable_public_widgets",
|
||||
"widget_custom_css",
|
||||
"home_scrobble_limit",
|
||||
"live_now_playing",
|
||||
"weigh_in_units",
|
||||
]
|
||||
widgets = {
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-22 02:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0038_userprofile_media_type_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="live_now_playing",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -98,6 +98,8 @@ class UserProfile(TimeStampedModel):
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
|
||||
live_now_playing = models.BooleanField(default=False)
|
||||
|
||||
weigh_in_units = models.CharField(
|
||||
max_length=16,
|
||||
choices=WeighUnit.choices,
|
||||
|
||||
@ -25,16 +25,24 @@ AUTO_FINISH_MEDIA = {
|
||||
}
|
||||
|
||||
PLAY_AGAIN_MEDIA = {
|
||||
"videogames": "VideoGame",
|
||||
"videos": "Video",
|
||||
"music": "Track",
|
||||
"podcasts": "PodcastEpisode",
|
||||
"sports": "SportEvent",
|
||||
"books": "Book",
|
||||
"videogames": "VideoGame",
|
||||
"boardgames": "BoardGame",
|
||||
"moods": "Mood",
|
||||
"bricksets": "BrickSet",
|
||||
"locations": "GeoLocation",
|
||||
"trails": "Trail",
|
||||
"beers": "Beer",
|
||||
"puzzles": "Puzzle",
|
||||
"foods": "Food",
|
||||
"locations": "GeoLocation",
|
||||
"videos": "Video",
|
||||
"tasks": "Task",
|
||||
"webpages": "WebPage",
|
||||
"lifeevents": "LifeEvent",
|
||||
"moods": "Mood",
|
||||
"bricksets": "BrickSet",
|
||||
"channels": "Channel",
|
||||
"birds": "BirdingLocation",
|
||||
"discgolf": "DiscGolfCourse",
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import pytz
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||
@ -20,8 +19,6 @@ MONTH_COLORS = [
|
||||
"#db7a7a", # Dec
|
||||
]
|
||||
|
||||
CACHE_TTL = 60
|
||||
|
||||
|
||||
def month_color(request):
|
||||
from datetime import date
|
||||
@ -33,10 +30,8 @@ def now_playing(request):
|
||||
if not user.is_authenticated:
|
||||
return {}
|
||||
|
||||
cache_key = f"now_playing_list_{user.id}"
|
||||
now_playing_list = cache.get(cache_key)
|
||||
if now_playing_list is None:
|
||||
now_playing_list = list(
|
||||
return {
|
||||
"now_playing_list": list(
|
||||
Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
is_paused=False,
|
||||
@ -46,9 +41,5 @@ def now_playing(request):
|
||||
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
||||
)
|
||||
.select_related("track", "video", "podcast_episode")
|
||||
)
|
||||
cache.set(cache_key, now_playing_list, CACHE_TTL)
|
||||
|
||||
return {
|
||||
"now_playing_list": now_playing_list,
|
||||
),
|
||||
}
|
||||
|
||||
518
vrobbler/apps/scrobbles/mcp.py
Normal file
518
vrobbler/apps/scrobbles/mcp.py
Normal file
@ -0,0 +1,518 @@
|
||||
from mcp_server import MCPToolset
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
|
||||
|
||||
class ScrobbleToolset(MCPToolset):
|
||||
def list_recent_scrobbles(
|
||||
self,
|
||||
days: int = 7,
|
||||
media_type: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""List scrobbles from the last N days, optionally filtered by media type.
|
||||
Valid media_type values: Video, Track, PodcastEpisode, SportEvent, Book,
|
||||
Paper, VideoGame, BoardGame, GeoLocation, Trail, Beer, Puzzle, Food, Task,
|
||||
WebPage, LifeEvent, Mood, BrickSet, Channel, BirdingLocation, DiscGolfCourse
|
||||
"""
|
||||
qs = (
|
||||
Scrobble.objects.filter(user=self.request.user)
|
||||
.select_related(
|
||||
"video", "track", "book", "video_game", "board_game",
|
||||
"beer", "puzzle", "food", "trail", "task", "web_page",
|
||||
"life_event", "mood", "brick_set", "podcast_episode",
|
||||
"sport_event", "geo_location", "birding_location",
|
||||
"disc_golf_course", "channel",
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
qs = qs[:limit]
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
def get_scrobble(self, uuid: str) -> dict | None:
|
||||
"""Get a single scrobble by its UUID."""
|
||||
try:
|
||||
s = Scrobble.objects.filter(user=self.request.user).get(uuid=uuid)
|
||||
except Scrobble.DoesNotExist:
|
||||
return None
|
||||
return _scrobble_to_dict(s)
|
||||
|
||||
def search_scrobbles(
|
||||
self, query: str, media_type: str | None = None, limit: int = 20
|
||||
) -> list[dict]:
|
||||
"""Search scrobbles by text in their log data or related media titles."""
|
||||
from django.db.models import Q
|
||||
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
qs = qs.filter(
|
||||
Q(log__icontains=query)
|
||||
| Q(video__title__icontains=query)
|
||||
| Q(track__title__icontains=query)
|
||||
| Q(book__title__icontains=query)
|
||||
| Q(video_game__title__icontains=query)
|
||||
| Q(board_game__title__icontains=query)
|
||||
| Q(beer__title__icontains=query)
|
||||
| Q(food__title__icontains=query)
|
||||
| Q(trail__title__icontains=query)
|
||||
| Q(task__title__icontains=query)
|
||||
| Q(web_page__title__icontains=query)
|
||||
| Q(life_event__title__icontains=query)
|
||||
| Q(puzzle__title__icontains=query)
|
||||
| Q(brick_set__title__icontains=query)
|
||||
| Q(podcast_episode__title__icontains=query)
|
||||
)[:limit]
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
def get_scrobbles_by_date(
|
||||
self, date: str, media_type: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Get scrobbles for a specific date (YYYY-MM-DD format)."""
|
||||
import datetime
|
||||
try:
|
||||
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return []
|
||||
qs = Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
timestamp__date=dt,
|
||||
).order_by("-timestamp")
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
def get_in_progress_scrobbles(
|
||||
self, media_type: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Get scrobbles currently in progress (started but not finished).
|
||||
These are long-play items like books, video games, brick sets, or tasks."""
|
||||
qs = Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
in_progress=True,
|
||||
).order_by("-timestamp")
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
def get_long_play_scrobbles(
|
||||
self, status: str = "in_progress", media_type: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Get long-play scrobbles (books, video games, brick sets, tasks).
|
||||
Status can be 'in_progress' or 'completed'."""
|
||||
types = list(LONG_PLAY_MEDIA.values())
|
||||
qs = Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
media_type__in=types,
|
||||
).order_by("-timestamp")
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
if status == "in_progress":
|
||||
qs = qs.filter(in_progress=True)
|
||||
elif status == "completed":
|
||||
qs = qs.filter(in_progress=False)
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
|
||||
class MediaToolset(MCPToolset):
|
||||
def get_book(self, uuid: str) -> dict | None:
|
||||
"""Get a book by UUID."""
|
||||
from books.models import Book
|
||||
try:
|
||||
b = Book.objects.get(uuid=uuid)
|
||||
except Book.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(b, fields=["title", "pages", "language",
|
||||
"first_publish_year", "isbn_13",
|
||||
"publisher", "summary"])
|
||||
|
||||
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List books, optionally filtered by author name."""
|
||||
from books.models import Book
|
||||
qs = Book.objects.all().order_by("title")
|
||||
if author:
|
||||
qs = qs.filter(authors__name__icontains=author)
|
||||
return [_media_to_dict(b, fields=["title", "pages", "language",
|
||||
"first_publish_year", "isbn_13",
|
||||
"publisher"]) for b in qs[:limit]]
|
||||
|
||||
def get_track(self, uuid: str) -> dict | None:
|
||||
"""Get a music track by UUID."""
|
||||
from music.models import Track
|
||||
try:
|
||||
t = Track.objects.select_related("artist_fk").get(uuid=uuid)
|
||||
except Track.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(t, fields=["title", "base_run_time_seconds",
|
||||
"artist_fk__name", "genre"])
|
||||
|
||||
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List music tracks, optionally filtered by artist name."""
|
||||
from music.models import Track
|
||||
qs = Track.objects.select_related("artist_fk").all().order_by("title")
|
||||
if artist:
|
||||
qs = qs.filter(artist_fk__name__icontains=artist)
|
||||
return [_media_to_dict(t, fields=["title", "base_run_time_seconds",
|
||||
"artist_fk__name", "genre"])
|
||||
for t in qs[:limit]]
|
||||
|
||||
def get_video(self, uuid: str) -> dict | None:
|
||||
"""Get a video by UUID."""
|
||||
from videos.models import Video
|
||||
try:
|
||||
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
|
||||
except Video.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(v, fields=["title", "year", "overview",
|
||||
"imdb_id", "imdb_rating",
|
||||
"tv_series__name", "channel__title",
|
||||
"season_number", "episode_number"])
|
||||
|
||||
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List videos, optionally filtered by series name."""
|
||||
from videos.models import Video
|
||||
qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")
|
||||
if series:
|
||||
qs = qs.filter(tv_series__name__icontains=series)
|
||||
return [_media_to_dict(v, fields=["title", "year", "overview",
|
||||
"tv_series__name", "channel__title",
|
||||
"season_number", "episode_number"])
|
||||
for v in qs[:limit]]
|
||||
|
||||
def get_board_game(self, uuid: str) -> dict | None:
|
||||
"""Get a board game by UUID."""
|
||||
from boardgames.models import BoardGame
|
||||
try:
|
||||
bg = BoardGame.objects.get(uuid=uuid)
|
||||
except BoardGame.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(bg, fields=["title", "genre"])
|
||||
|
||||
def list_board_games(self, limit: int = 20) -> list[dict]:
|
||||
"""List board games."""
|
||||
from boardgames.models import BoardGame
|
||||
qs = BoardGame.objects.all().order_by("title")[:limit]
|
||||
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
|
||||
|
||||
def get_podcast_episode(self, uuid: str) -> dict | None:
|
||||
"""Get a podcast episode by UUID."""
|
||||
from podcasts.models import PodcastEpisode
|
||||
try:
|
||||
pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
|
||||
uuid=uuid
|
||||
)
|
||||
except PodcastEpisode.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(pe, fields=["title", "podcast__title",
|
||||
"producer__name", "base_run_time_seconds"])
|
||||
|
||||
def get_beer(self, uuid: str) -> dict | None:
|
||||
"""Get a beer by UUID."""
|
||||
from beers.models import Beer
|
||||
try:
|
||||
b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
|
||||
except Beer.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(b, fields=["title", "style__name",
|
||||
"producer__name", "abv"])
|
||||
|
||||
def get_brick_set(self, uuid: str) -> dict | None:
|
||||
"""Get a brick set (LEGO) by UUID."""
|
||||
from bricksets.models import BrickSet
|
||||
try:
|
||||
bs = BrickSet.objects.get(uuid=uuid)
|
||||
except BrickSet.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(bs, fields=["title", "piece_count", "set_number"])
|
||||
|
||||
def get_video_game(self, uuid: str) -> dict | None:
|
||||
"""Get a video game by UUID."""
|
||||
from videogames.models import VideoGame
|
||||
try:
|
||||
vg = VideoGame.objects.get(uuid=uuid)
|
||||
except VideoGame.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(vg, fields=["title", "genre",
|
||||
"base_run_time_seconds"])
|
||||
|
||||
def get_puzzle(self, uuid: str) -> dict | None:
|
||||
"""Get a puzzle by UUID."""
|
||||
from puzzles.models import Puzzle
|
||||
try:
|
||||
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
|
||||
except Puzzle.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(p, fields=["title", "piece_count",
|
||||
"manufacturer__name"])
|
||||
|
||||
def get_web_page(self, uuid: str) -> dict | None:
|
||||
"""Get a web page by UUID."""
|
||||
from webpages.models import WebPage
|
||||
try:
|
||||
wp = WebPage.objects.select_related("domain").get(uuid=uuid)
|
||||
except WebPage.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(wp, fields=["title", "url", "domain__name"])
|
||||
|
||||
def get_task(self, uuid: str) -> dict | None:
|
||||
"""Get a task by UUID."""
|
||||
from tasks.models import Task
|
||||
try:
|
||||
t = Task.objects.get(uuid=uuid)
|
||||
except Task.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(t, fields=["title", "completed"])
|
||||
|
||||
def get_trail(self, uuid: str) -> dict | None:
|
||||
"""Get a trail by UUID."""
|
||||
from trails.models import Trail
|
||||
try:
|
||||
t = Trail.objects.get(uuid=uuid)
|
||||
except Trail.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(t, fields=["title", "genre", "base_run_time_seconds"])
|
||||
|
||||
def get_geo_location(self, uuid: str) -> dict | None:
|
||||
"""Get a geo location by UUID."""
|
||||
from locations.models import GeoLocation
|
||||
try:
|
||||
gl = GeoLocation.objects.get(uuid=uuid)
|
||||
except GeoLocation.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(gl, fields=["title", "latitude", "longitude"])
|
||||
|
||||
def get_life_event(self, uuid: str) -> dict | None:
|
||||
"""Get a life event by UUID."""
|
||||
from lifeevents.models import LifeEvent
|
||||
try:
|
||||
le = LifeEvent.objects.get(uuid=uuid)
|
||||
except LifeEvent.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(le, fields=["title", "event_date", "genre"])
|
||||
|
||||
def get_mood(self, uuid: str) -> dict | None:
|
||||
"""Get a mood entry by UUID."""
|
||||
from moods.models import Mood
|
||||
try:
|
||||
m = Mood.objects.get(uuid=uuid)
|
||||
except Mood.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(m, fields=["title", "mood_type", "mood_quality"])
|
||||
|
||||
def get_food(self, uuid: str) -> dict | None:
|
||||
"""Get a food entry by UUID."""
|
||||
from foods.models import Food
|
||||
try:
|
||||
f = Food.objects.select_related("category").get(uuid=uuid)
|
||||
except Food.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(f, fields=["title", "category__name"])
|
||||
|
||||
def get_bird_sighting(self, uuid: str) -> dict | None:
|
||||
"""Get a bird sighting by UUID."""
|
||||
from birds.models import BirdSighting
|
||||
try:
|
||||
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
|
||||
except BirdSighting.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(bs, fields=["title", "bird__common_name",
|
||||
"bird__scientific_name", "location"])
|
||||
|
||||
def get_disc_golf_course(self, uuid: str) -> dict | None:
|
||||
"""Get a disc golf course by UUID."""
|
||||
from discgolf.models import DiscGolfCourse
|
||||
try:
|
||||
dg = DiscGolfCourse.objects.get(uuid=uuid)
|
||||
except DiscGolfCourse.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(dg, fields=["title", "holes", "location"])
|
||||
|
||||
|
||||
class StatsToolset(MCPToolset):
|
||||
def get_scrobble_counts(self, days: int = 30) -> list[dict]:
|
||||
"""Get scrobble counts grouped by media type for the last N days."""
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
from django.db.models import Count
|
||||
|
||||
cutoff = timezone.now() - datetime.timedelta(days=days)
|
||||
qs = (
|
||||
Scrobble.objects.filter(user=self.request.user, timestamp__gte=cutoff)
|
||||
.values("media_type")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
return list(qs)
|
||||
|
||||
def get_top_media(
|
||||
self, media_type: str, days: int = 30, limit: int = 10
|
||||
) -> list[dict]:
|
||||
"""Get the most-scrobbled items of a given media type in the last N days.
|
||||
Valid media_type values: Video, Track, Book, BoardGame, Beer, etc."""
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
from django.db.models import Count
|
||||
|
||||
cutoff = timezone.now() - datetime.timedelta(days=days)
|
||||
|
||||
rel_field = _media_type_to_rel_field(media_type)
|
||||
if not rel_field:
|
||||
return []
|
||||
|
||||
qs = (
|
||||
Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
media_type=media_type,
|
||||
timestamp__gte=cutoff,
|
||||
)
|
||||
.values(rel_field)
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:limit]
|
||||
|
||||
results = []
|
||||
for row in qs:
|
||||
obj_id = row[rel_field]
|
||||
if obj_id is None:
|
||||
continue
|
||||
results.append({"id": obj_id, "count": row["count"]})
|
||||
return results
|
||||
|
||||
|
||||
def _media_type_to_rel_field(media_type: str) -> str | None:
|
||||
mapping = {
|
||||
"Video": "video",
|
||||
"Track": "track",
|
||||
"PodcastEpisode": "podcast_episode",
|
||||
"SportEvent": "sport_event",
|
||||
"Book": "book",
|
||||
"Paper": "paper",
|
||||
"VideoGame": "video_game",
|
||||
"BoardGame": "board_game",
|
||||
"GeoLocation": "geo_location",
|
||||
"Trail": "trail",
|
||||
"Beer": "beer",
|
||||
"Puzzle": "puzzle",
|
||||
"Food": "food",
|
||||
"Task": "task",
|
||||
"WebPage": "web_page",
|
||||
"LifeEvent": "life_event",
|
||||
"Mood": "mood",
|
||||
"BrickSet": "brick_set",
|
||||
"Channel": "channel",
|
||||
"BirdingLocation": "birding_location",
|
||||
"DiscGolfCourse": "disc_golf_course",
|
||||
}
|
||||
return mapping.get(media_type)
|
||||
|
||||
|
||||
def _scrobble_to_dict(s: Scrobble) -> dict:
|
||||
result = {
|
||||
"uuid": str(s.uuid),
|
||||
"media_type": s.media_type,
|
||||
"timestamp": s.timestamp.isoformat() if s.timestamp else None,
|
||||
"stop_timestamp": s.stop_timestamp.isoformat() if s.stop_timestamp else None,
|
||||
"in_progress": s.in_progress,
|
||||
"played_to_completion": s.played_to_completion,
|
||||
"source": s.source,
|
||||
"visibility": s.visibility,
|
||||
"timezone": s.timezone,
|
||||
}
|
||||
if s.log:
|
||||
result["log"] = s.log
|
||||
|
||||
rel = _scrobble_related_to_dict(s)
|
||||
if rel:
|
||||
result["media"] = rel
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
||||
if s.video:
|
||||
return _media_to_dict(s.video, fields=["title", "year", "imdb_id",
|
||||
"imdb_rating"])
|
||||
if s.track:
|
||||
return _media_to_dict(s.track, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
if s.book:
|
||||
return _media_to_dict(s.book, fields=["title", "pages"])
|
||||
if s.video_game:
|
||||
return _media_to_dict(s.video_game, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
if s.board_game:
|
||||
return _media_to_dict(s.board_game, fields=["title"])
|
||||
if s.beer:
|
||||
return _media_to_dict(s.beer, fields=["title"])
|
||||
if s.puzzle:
|
||||
return _media_to_dict(s.puzzle, fields=["title", "piece_count"])
|
||||
if s.food:
|
||||
return _media_to_dict(s.food, fields=["title"])
|
||||
if s.trail:
|
||||
return _media_to_dict(s.trail, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
if s.task:
|
||||
return _media_to_dict(s.task, fields=["title"])
|
||||
if s.web_page:
|
||||
return _media_to_dict(s.web_page, fields=["title", "url"])
|
||||
if s.life_event:
|
||||
return _media_to_dict(s.life_event, fields=["title", "event_date"])
|
||||
if s.mood:
|
||||
return _media_to_dict(s.mood, fields=["title"])
|
||||
if s.brick_set:
|
||||
return _media_to_dict(s.brick_set, fields=["title", "set_number"])
|
||||
if s.podcast_episode:
|
||||
return _media_to_dict(s.podcast_episode, fields=["title"])
|
||||
if s.sport_event:
|
||||
return {"title": str(s.sport_event)}
|
||||
if s.geo_location:
|
||||
return _media_to_dict(s.geo_location, fields=["title", "latitude",
|
||||
"longitude"])
|
||||
if s.birding_location:
|
||||
return _media_to_dict(s.birding_location, fields=["title"])
|
||||
if s.disc_golf_course:
|
||||
return _media_to_dict(s.disc_golf_course, fields=["title", "holes"])
|
||||
if s.channel:
|
||||
return _media_to_dict(s.channel, fields=["title"])
|
||||
return None
|
||||
|
||||
|
||||
def _media_to_dict(obj, fields: list[str] | None = None) -> dict:
|
||||
if obj is None:
|
||||
return {}
|
||||
result = {}
|
||||
if hasattr(obj, "uuid"):
|
||||
result["uuid"] = str(obj.uuid)
|
||||
if hasattr(obj, "title"):
|
||||
result["title"] = obj.title
|
||||
|
||||
if fields is None:
|
||||
return result
|
||||
|
||||
resolved = _resolve_fields(obj, fields)
|
||||
for k, v in resolved.items():
|
||||
if k not in result:
|
||||
result[k] = v
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_fields(obj, fields: list[str]) -> dict:
|
||||
result = {}
|
||||
for field in fields:
|
||||
parts = field.split("__")
|
||||
val = obj
|
||||
try:
|
||||
for part in parts:
|
||||
val = getattr(val, part)
|
||||
except AttributeError:
|
||||
continue
|
||||
if val is not None:
|
||||
if hasattr(val, "all"):
|
||||
val = [str(v) for v in val.all()]
|
||||
result[field] = val
|
||||
return result
|
||||
@ -5,6 +5,7 @@ from tasks.webhooks import EmacsWebhookView, TodoistWebhookView
|
||||
app_name = "scrobbles"
|
||||
|
||||
urlpatterns = [
|
||||
path("now-playing/", views.NowPlayingPartialView.as_view(), name="now-playing-partial"),
|
||||
path("calendar/", views.ScrobbleCalendarView.as_view(), name="calendar"),
|
||||
path("search/", views.ScrobbleSearchView.as_view(), name="search"),
|
||||
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
|
||||
|
||||
@ -361,11 +361,35 @@ class RecentScrobbleList(ListView):
|
||||
return Scrobble.objects.all().order_by("-timestamp")
|
||||
|
||||
|
||||
class NowPlayingPartialView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "scrobbles/_now_playing.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||
|
||||
ctx["now_playing_list"] = list(
|
||||
Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
is_paused=False,
|
||||
user=self.request.user,
|
||||
)
|
||||
.exclude(media_type__in=EXCLUDE_FROM_NOW_PLAYING)
|
||||
.select_related("track", "video", "podcast_episode")
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
model = Scrobble
|
||||
paginate_by = 100
|
||||
template_name = "scrobbles/scrobble_all_list.html"
|
||||
|
||||
def get_template_names(self):
|
||||
if self.request.headers.get("HX-Request"):
|
||||
return ["scrobbles/_scrobble_all_content.html"]
|
||||
return ["scrobbles/scrobble_all_list.html"]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
|
||||
tags_param = self.request.GET.get("tags", "")
|
||||
@ -894,7 +918,7 @@ def scrobble_start(request, media_uuid):
|
||||
if last_scrobble and last_scrobble.logdata:
|
||||
next_page = last_scrobble.logdata.page_end + 1
|
||||
log_data = {"page_start": next_page}
|
||||
media_obj.scrobble_for_user(user_id, log=log_data)
|
||||
scrobble = media_obj.scrobble_for_user(user_id, log=log_data)
|
||||
|
||||
if scrobble:
|
||||
messages.add_message(
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-22 02:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"trends",
|
||||
"0002_alter_trendresult_unique_together_trendresult_period_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="trendresult",
|
||||
name="period",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
],
|
||||
default="last_30",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -15,13 +15,11 @@ def natural_duration(value):
|
||||
seconds = remainder % 60
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
parts.append(f"{days} d")
|
||||
if hours:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
parts.append(f"{hours} hr")
|
||||
if minutes:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
parts.append(f"{minutes} min")
|
||||
if seconds or not parts:
|
||||
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return ", ".join(parts[:-1]) + " and " + parts[-1]
|
||||
parts.append(f"{seconds} sec")
|
||||
return ", ".join(parts)
|
||||
|
||||
@ -236,6 +236,7 @@ INSTALLED_APPS = [
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"django_celery_results",
|
||||
"mcp_server",
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
@ -329,6 +330,13 @@ REST_FRAMEWORK = {
|
||||
"PAGE_SIZE": 200,
|
||||
}
|
||||
|
||||
DJANGO_MCP_AUTHENTICATION_CLASSES = [
|
||||
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
]
|
||||
DJANGO_MCP_GET_SERVER_INSTRUCTIONS_TOOL = True
|
||||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
<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://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<style type="text/css">
|
||||
dl {
|
||||
display: flex;
|
||||
@ -310,26 +311,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% if now_playing_list and user.is_authenticated %}
|
||||
<ul>
|
||||
<b>Now playing</b>
|
||||
{% for scrobble in now_playing_list %}
|
||||
<div class="now-playing">
|
||||
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
|
||||
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
|
||||
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
|
||||
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
|
||||
<div class="progress-bar" style="margin-right:5px;">
|
||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||
</div>
|
||||
<p class="action-buttons">
|
||||
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
|
||||
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
|
||||
</p>
|
||||
{% if not forloop.last %}<hr/>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if now_playing_list|length > 1 %}<hr/>{% endif %}
|
||||
{% if user.profile.live_now_playing %}
|
||||
<div id="now-playing-container" hx-get="{% url 'scrobbles:now-playing-partial' %}" hx-trigger="every 7s" hx-swap="innerHTML">
|
||||
{% include "scrobbles/_now_playing.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "scrobbles/_now_playing.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if active_imports %}
|
||||
|
||||
21
vrobbler/templates/scrobbles/_now_playing.html
Normal file
21
vrobbler/templates/scrobbles/_now_playing.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% load humanize %}
|
||||
<ul>
|
||||
<b>Now playing</b>
|
||||
{% for scrobble in now_playing_list %}
|
||||
<div class="now-playing">
|
||||
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
|
||||
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
|
||||
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
|
||||
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
|
||||
<div class="progress-bar" style="margin-right:5px;">
|
||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||
</div>
|
||||
<p class="action-buttons">
|
||||
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
|
||||
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
|
||||
</p>
|
||||
{% if not forloop.last %}<hr/>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if now_playing_list|length > 1 %}<hr/>{% endif %}
|
||||
146
vrobbler/templates/scrobbles/_scrobble_all_content.html
Normal file
146
vrobbler/templates/scrobbles/_scrobble_all_content.html
Normal file
@ -0,0 +1,146 @@
|
||||
{% load humanize %}
|
||||
{% load naturalduration %}
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4" hx-get="{% url 'scrobbles:scrobble-list' %}{% if request.META.QUERY_STRING %}?{{ request.META.QUERY_STRING }}{% endif %}" hx-trigger="every 7s" hx-swap="outerHTML">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">All Scrobbles</h1>
|
||||
{% if tag_list %}
|
||||
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
|
||||
{% if total_time_seconds %}
|
||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.GET.visibility %}
|
||||
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr class="{% if scrobble.id in overlap_map %}{{ overlap_map.scrobble.id }}{% endif %}">
|
||||
<td>
|
||||
{% if scrobble.id in overlap_map %}⏱ {% endif %}
|
||||
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.video %}
|
||||
🎬 Video
|
||||
{% elif scrobble.track %}
|
||||
🎵 Track
|
||||
{% elif scrobble.podcast_episode %}
|
||||
🎙️ Podcast episode
|
||||
{% elif scrobble.sport_event %}
|
||||
⚽ Sport event
|
||||
{% elif scrobble.book %}
|
||||
📖 Book
|
||||
{% elif scrobble.paper %}
|
||||
📄 Paper
|
||||
{% elif scrobble.video_game %}
|
||||
🎮 Video game
|
||||
{% elif scrobble.board_game %}
|
||||
🎲 Board game
|
||||
{% elif scrobble.geo_location %}
|
||||
📍 GeoLocation
|
||||
{% elif scrobble.trail %}
|
||||
🥾 Trail
|
||||
{% elif scrobble.beer %}
|
||||
🍺 Beer
|
||||
{% elif scrobble.puzzle %}
|
||||
🧩 Puzzle
|
||||
{% elif scrobble.food %}
|
||||
🍔 Food
|
||||
{% elif scrobble.task %}
|
||||
✅ Task
|
||||
{% elif scrobble.web_page %}
|
||||
🌐 Web Page
|
||||
{% elif scrobble.life_event %}
|
||||
🎉 Life event
|
||||
{% elif scrobble.mood %}
|
||||
😊 Mood
|
||||
{% elif scrobble.brick_set %}
|
||||
🧱 Brick set
|
||||
{% elif scrobble.channel %}
|
||||
📺 Channel
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.video %}
|
||||
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
|
||||
{% elif scrobble.track %}
|
||||
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
|
||||
{% elif scrobble.video_game %}
|
||||
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
|
||||
{% elif scrobble.book %}
|
||||
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
|
||||
{% elif scrobble.food %}
|
||||
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
|
||||
{% elif scrobble.beer %}
|
||||
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||
{% elif scrobble.podcast_episode %}
|
||||
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
|
||||
{% elif scrobble.board_game %}
|
||||
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
|
||||
{% elif scrobble.trail %}
|
||||
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
|
||||
{% elif scrobble.puzzle %}
|
||||
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
|
||||
{% elif scrobble.brick_set %}
|
||||
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
|
||||
{% elif scrobble.task %}
|
||||
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
|
||||
{% elif scrobble.life_event %}
|
||||
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
|
||||
{% elif scrobble.mood %}
|
||||
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
|
||||
{% elif scrobble.geo_location %}
|
||||
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.in_progress and not scrobble.played_to_completion %}
|
||||
In progress ...
|
||||
{% elif scrobble.playback_position_seconds %}
|
||||
{{ scrobble.playback_position_seconds|natural_duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No scrobbles found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_previous or page_obj.has_next %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
@ -1,148 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% load humanize %}
|
||||
{% load naturalduration %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<div>
|
||||
<h1 class="h2">All Scrobbles</h1>
|
||||
{% if tag_list %}
|
||||
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
|
||||
{% if total_time_seconds %}
|
||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.GET.visibility %}
|
||||
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr class="{% if scrobble.id in overlap_map %}{{ overlap_map.scrobble.id }}{% endif %}">
|
||||
<td>
|
||||
{% if scrobble.id in overlap_map %}⏱ {% endif %}
|
||||
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.video %}
|
||||
🎬 Video
|
||||
{% elif scrobble.track %}
|
||||
🎵 Track
|
||||
{% elif scrobble.podcast_episode %}
|
||||
🎙️ Podcast episode
|
||||
{% elif scrobble.sport_event %}
|
||||
⚽ Sport event
|
||||
{% elif scrobble.book %}
|
||||
📖 Book
|
||||
{% elif scrobble.paper %}
|
||||
📄 Paper
|
||||
{% elif scrobble.video_game %}
|
||||
🎮 Video game
|
||||
{% elif scrobble.board_game %}
|
||||
🎲 Board game
|
||||
{% elif scrobble.geo_location %}
|
||||
📍 GeoLocation
|
||||
{% elif scrobble.trail %}
|
||||
🥾 Trail
|
||||
{% elif scrobble.beer %}
|
||||
🍺 Beer
|
||||
{% elif scrobble.puzzle %}
|
||||
🧩 Puzzle
|
||||
{% elif scrobble.food %}
|
||||
🍔 Food
|
||||
{% elif scrobble.task %}
|
||||
✅ Task
|
||||
{% elif scrobble.web_page %}
|
||||
🌐 Web Page
|
||||
{% elif scrobble.life_event %}
|
||||
🎉 Life event
|
||||
{% elif scrobble.mood %}
|
||||
😊 Mood
|
||||
{% elif scrobble.brick_set %}
|
||||
🧱 Brick set
|
||||
{% elif scrobble.channel %}
|
||||
📺 Channel
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.video %}
|
||||
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
|
||||
{% elif scrobble.track %}
|
||||
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
|
||||
{% elif scrobble.video_game %}
|
||||
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
|
||||
{% elif scrobble.book %}
|
||||
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
|
||||
{% elif scrobble.food %}
|
||||
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
|
||||
{% elif scrobble.beer %}
|
||||
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||
{% elif scrobble.podcast_episode %}
|
||||
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
|
||||
{% elif scrobble.board_game %}
|
||||
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
|
||||
{% elif scrobble.trail %}
|
||||
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
|
||||
{% elif scrobble.puzzle %}
|
||||
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
|
||||
{% elif scrobble.brick_set %}
|
||||
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
|
||||
{% elif scrobble.task %}
|
||||
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
|
||||
{% elif scrobble.life_event %}
|
||||
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
|
||||
{% elif scrobble.mood %}
|
||||
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
|
||||
{% elif scrobble.geo_location %}
|
||||
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if scrobble.playback_position_seconds %}
|
||||
{{ scrobble.playback_position_seconds|natural_duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No scrobbles found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_previous or page_obj.has_next %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
{% include "scrobbles/_scrobble_all_content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@ -73,24 +73,19 @@
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
|
||||
</div>
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
|
||||
</div>
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
|
||||
</div>
|
||||
<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">
|
||||
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
|
||||
@ -105,19 +100,6 @@
|
||||
data-bs-target="#exportModal">Export</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton"
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<div data-feather="calendar"></div>
|
||||
{{title}}
|
||||
</button>
|
||||
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
|
||||
<a class="dropdown-item" href="?date=today">Today</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>
|
||||
|
||||
|
||||
@ -188,6 +188,7 @@ urlpatterns = [
|
||||
path("", include(people_urls, namespace="people")),
|
||||
path("", include(charts_urls, namespace="charts")),
|
||||
path("", include(trends_urls, namespace="trends")),
|
||||
path("", include("mcp_server.urls")),
|
||||
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
|
||||
]
|
||||
if settings.DEBUG:
|
||||
|
||||
Reference in New Issue
Block a user