Compare commits

...

14 Commits
47.1 ... 48.2

Author SHA1 Message Date
5f55ec557f [release] Bump to version 48.2
Some checks failed
build / test (push) Failing after 1m21s
deploy / test (push) Failing after 1m18s
deploy / build-and-deploy (push) Has been skipped
- Lock down scrobbles and use sqids to share them
2026-06-09 12:33:50 -04:00
7f3076608f [scrobbles] Add sharing of scrobbles
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:33:25 -04:00
568772a0e6 [release] Bump to version 48.1
All checks were successful
deploy / test (push) Successful in 2m0s
build / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 35s
- Generate a report of tracks with mistmatched metadata
- Date parsing failing in eBird imports
2026-06-08 11:18:08 -04:00
91c3376256 [music] Add mgmt command to see mismatched metadata
Some checks failed
build / test (push) Has been cancelled
2026-06-08 11:17:36 -04:00
58639c6fc1 [importers] Add error_log to surface import errors
All checks were successful
build / test (push) Successful in 2m2s
2026-06-08 10:58:02 -04:00
228441ddc5 [importers] Fix parsing of dates in ebird files 2026-06-08 10:52:50 -04:00
6341075f07 [agent] Add some notes about how we track tasks 2026-06-08 10:52:32 -04:00
a135b9f5f2 [project] Update format for PROJECT file
All checks were successful
build / test (push) Successful in 1m58s
2026-06-07 11:10:30 -04:00
9088412d1e [release] Bump to version 48.0
All checks were successful
build / test (push) Successful in 2m1s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 33s
- Show team or player images on sport detail and scrobble detail
- Add fix_metadta method to Video instances
2026-06-07 11:06:58 -04:00
c7339fbe31 [templates] Clean up how str and subtitles work 2026-06-07 11:06:30 -04:00
4ce3dc03c5 [videos] Add fix_metadata for videos 2026-06-07 10:13:55 -04:00
5a4ef678a8 [release] Bump to version 47.2
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 53s
- Add OMDB source as backup when TMDB returns nothing
2026-06-07 09:59:05 -04:00
5ca22efeaa [videos] Fix full metadata from OMDB
We were missing the series and episode number info.
2026-06-07 09:58:28 -04:00
912ea8bfac [videos] Add OMDB enrichment when TMDb fails 2026-06-07 09:47:52 -04:00
41 changed files with 1694 additions and 148 deletions

View File

@ -14,3 +14,11 @@ ro class method should call the utility function.
Be sure to check pyproject.toml for project defaults. Specifically for black and
isort expectations.
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
In local development, environment variables for various sensitive values live in a .envrc file
The .envrc file can be loaded into a shell environment to allow access to most third party services
Care should be taken when using .envrc that we do not spam services we use in production with requests

View File

@ -17,6 +17,7 @@ tasks, Todoist tasks, web pages I've read and trails I've hiked has turned out
to be sometimes cathartic and sometimes functional as I try to remember when I
did a thing.
* Features
** Beer
*** Triggers
@ -86,7 +87,8 @@ fetching and simple saving.
**** Bookmarklet
*** Metadata sources
**** Scraper
* Backlog [0/13] :vrobbler:project:personal:
* Backlog [0/14] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -498,7 +500,131 @@ needed import celery task. This is how the WebDAV celery task currently works.
This would also be an opporunity to clean up the code around WebDAV imports
and make them more re-usable for other import services.
** TODO [#B] Add OMDB source as backup when TMDB returns nothing :videos:metadata:imdb:
** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata:
:PROPERTIES:
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
:END:
*** Description
Certain artists like "Simon & Garfunkel" are actually one artist. While we don't want to mess with splitting up
tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like
this put into it and then we stop trying to pull the artist apart when we run into it.
** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata:
:PROPERTIES:
:ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0
:END:
*** Description
Both Jellyfin and Mopidy provide a decent amount of metadata when they POST to our webhooks.
In most cases, we should be able to trust this data to created music tracks or videos rather
than going to third-party services to enrich. Thus, for tracks and videos we should search in
the local database for imdb_id or musicbrainz_id for the specific content and, if found, not
enrich further.
If not found, tracks and videos from mopidy and jellyfin should be created as completely as
possible using only the POST data from the webhooks, tagged the scrobble with "webhook-metadata-only"
and start the scrobble. A separate celery task should be kicked off to enrich the track or video
async with the POST data stored in the log["raw_data"] and used by the celery enrichment task
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
log a warning and move on.
* Version 48.2 [1/1]
** DONE [#A] Lock down scrobbles and use sqids to share them :feature:sharing:scrobbles:
:PROPERTIES:
:ID: a6e869f7-8012-7e83-8f68-d0a0ed4c3c6a
:END:
*** Description
Currently all scrobbles are public. Anyone with the uuid can view any other
scrobbles. We should use SQIDs to allow shareable links to scrobbles and then
make all scrobbles hidden by default.
* Version 48.1 [2/2]
** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata:
:PROPERTIES:
:ID: 684b8cd2-a3c1-4995-ba9e-7abdb02c37f2
:END:
*** Description
We should have a management command that outputs a CSV file of track IDs where
the log["raw_data"]["Artist"] (for Jellyfin) or log["raw_data"]["artist"]
(mopidy) value does not match the Track.artists names.
And we should see the same thing for albums (log["raw_data"]["Album"] or
log["raw_data]["album"]).
It should output the fields "track_id", "track_artist_name", "track_album_name",
"raw_artist", "raw_album", "source"
Where source is either Jellyfin or Mopidy based on the keys.
Put the file /tmp/metadata-report.csv by default and overwrite exsiting reports.
The command should also accept a file-path to overide this default.
** DONE [#A] Date parsing failing in eBird imports :birds:ebird:importers:
:PROPERTIES:
:ID: 72e0254f-39d8-4843-9857-623e0362d77e
:END:
*** Description
On line 45 in the apps/birds/importer.py file, the import is thorowing this
error:
#+begin_src python
ValueError: time data 'Jun 7, 2026, 5:15 PM' does not match format '%B %d, %Y %I:%M %p'
#+end_src
Historically other files starting on May 24 worked, so I suspect this is a
problem of the date formatter expecting Long month names and zero-padded days
and the only sample we had was a three-letter month (May) and days with two
digites(24 through the 31)
We should also add a "error_log" to the importers so that errors tha occur are
surfaced, even when 0 successful files were processed. And we should make sure
all importers do this as well.
* Version 48.0 [2/2]
** DONE [#B] Show team or player images on sport detail and scrobble detail :sports:templates:
:PROPERTIES:
:ID: 68c17383-ee6e-4b5f-b3f5-1b637a0a3ea8
:END:
*** Description
On the sport event detail page, we should show the images of the teams or
players invovled.
Also, those images for the sport event should be shown on the scrobble detail
page for sport event scrobble details.
** DONE [#B] Add fix_metadta method to Video instances :videos:metadata:
:PROPERTIES:
:ID: 9df5404d-1b60-4eee-b7cf-1f7e6dfade65
:END:
*** Description
Turns out we don't have a fix_metadata method for videos. We should add that using
the basic logic from find_or_create on the Video model.
* Version 47.2 [1/1]
** DONE [#B] Add OMDB source as backup when TMDB returns nothing :videos:metadata:imdb:
:PROPERTIES:
:ID: 20195445-7fdd-49be-9767-103b12da0bfb
:END:
@ -512,12 +638,14 @@ epiodes up in three parts, while they were always broadcast three-in-one, and
that's how IMDB lists them. Thus, the IMDB ID means nothing, and the videos end
up unenriched.
* Version 47.1 [1/1]
** DONE [#A] Untangle the sports migrations errors :sports:bug:migrations:
:PROPERTIES:
:ID: 4d50ca2e-f45b-dde8-e3c9-cd84f353b349
:END:
* Version 47.0 [1/1]
** DONE [#B] Change sports scrobbling a bit :feature:sports:scrobbles:
:PROPERTIES:
@ -933,6 +1061,7 @@ Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
:END:
* Version 39.0 [3/3]
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
:PROPERTIES:
@ -987,6 +1116,7 @@ If the task is completed, don't touch it.
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
:END:
* Version 38.0 [38/38]
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
:PROPERTIES:
@ -1378,6 +1508,7 @@ urllib.error.HTTPError: HTTP Error 500: Internal Server Error
This may have already been resolved ... need to just confirm it.
* Version 37.0 [4/4]
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
:PROPERTIES:
@ -1396,11 +1527,13 @@ https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
:PROPERTIES:
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
:END:
* Version 36.0 [1/1]
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
:PROPERTIES:
:ID: 6034a11d-5376-994d-9a4b-e1640e258cfa
:END:
* Version 35.0 [3/3]
** DONE [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
:PROPERTIES:
@ -1414,6 +1547,7 @@ https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
:PROPERTIES:
:ID: d1ba1ca1-509b-13a9-1307-b2dc94a2eafe
:END:
* Version 34.0 [4/4]
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
:PROPERTIES:
@ -1473,6 +1607,7 @@ TypeError: can only concatenate str (not "NoneType") to str
A good lesson in using constants for things.
* Version 33.0 [3/3]
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES:
@ -1487,6 +1622,7 @@ TypeError: can only concatenate str (not "NoneType") to str
:ID: 4955cc34-0882-50db-92f7-f36a95bf57a4
:END:
<2025-10-28 Tue>
* Version 32.0 [2/2]
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
:PROPERTIES:
@ -1496,6 +1632,7 @@ TypeError: can only concatenate str (not "NoneType") to str
:PROPERTIES:
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
:END:
* Version 31.0 [3/3]
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
:PROPERTIES:
@ -1509,6 +1646,7 @@ TypeError: can only concatenate str (not "NoneType") to str
:PROPERTIES:
:ID: 9a870c05-6d20-0803-d35d-c03fbe1d0ee1
:END:
* Version 30.0 [3/3]
** DONE [#A] Fix readcomicsonline browsing to update pages :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
@ -1523,10 +1661,13 @@ TypeError: can only concatenate str (not "NoneType") to str
:PROPERTIES:
:ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
:END:
* Version 29.0 [1/1]
** DONE HOTFIX podcast lookups, final
* Version 28.0 [1/1]
** DONE HOTFIX podcast lookups
* Version 27.0 [3/3]
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
:PROPERTIES:
@ -1567,6 +1708,7 @@ it's annoying.
actually be much more reliable than the current state of the podcast lookup
which depends on the file to be name properly.
* Version 26.0 [3/3]
** DONE Clean up templates for scrobble details :vrobbler:personal:bug:templates:
:PROPERTIES:
@ -1580,6 +1722,7 @@ it's annoying.
:PROPERTIES:
:ID: c03a38ce-b337-f4fa-adba-aee08d4329f5
:END:
* Version 25.0 [3/3]
** DONE Add basic food templates and fix urls :food:vrobbler:personal:project:bug:urls:
:PROPERTIES:
@ -1593,6 +1736,7 @@ it's annoying.
:PROPERTIES:
:ID: 7debfbaf-cdd8-f49b-57ff-804bfe7c9236
:END:
* Version 24.0 [2/2]
** DONE Clean up logdata for various media :personal:feature:project:vrobbler:logdata:
:PROPERTIES:
@ -1603,6 +1747,7 @@ it's annoying.
:ID: 1a1c0aa6-0313-c8be-1676-5d6adddef0a4
:END:
* Version 23.0 [3/3]
** DONE Add dynamic forms for LogData classes :personal:feature:vrobbler:project:forms:logdata:
:PROPERTIES:
@ -1613,16 +1758,19 @@ it's annoying.
:PROPERTIES:
:ID: 99f6bd77-dc8f-6ed1-0321-32a52c944264
:END:
* Version 19.0 [1/1]
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
:PROPERTIES:
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
:END:
* Version 18.7 [1/1]
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
:PROPERTIES:
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
:END:
* Version 18.4 [2/2]
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
:PROPERTIES:
@ -1636,11 +1784,13 @@ it's annoying.
- Note taken on [2025-07-20 Sun 16:21]
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
* Version 18.3 [1/1]
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
:PROPERTIES:
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
:END:
* Version 18 [4/4]
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
:PROPERTIES:
@ -1762,6 +1912,7 @@ it's annoying.
:PROPERTIES:
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c
:END:
* Version 17.0 [6/6]
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
:PROPERTIES:
@ -1825,6 +1976,7 @@ Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
:PROPERTIES:
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
:END:
* Version 0.16.0 [19/19]
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES:
@ -1930,6 +2082,7 @@ out using that.
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
This was fixed a while ago, but there's a new manifested bug. Going to create a
separate bug tracking ticket for that.
* Version 0.11.4 [9/9]
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "47.1"
version = "48.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -1,3 +1,4 @@
import tempfile
from datetime import timedelta
import pytest
@ -128,6 +129,23 @@ class TestBirdingCSVImportModel:
assert imp.import_type == "Birding CSV"
assert "Birding" in str(imp)
def test_record_error(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
assert imp.error_log is None
imp.record_error("test error")
imp.refresh_from_db()
assert imp.error_log is not None
assert "test error" in imp.error_log
def test_record_error_appends(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
imp.record_error("first error")
imp.record_error("second error")
imp.refresh_from_db()
assert imp.error_log.count("\n") == 1
assert "first error" in imp.error_log
assert "second error" in imp.error_log
@pytest.mark.django_db(transaction=True)
def test_process_via_model(self, user, birding_csv_file):
imp = BirdingCSVImport.objects.create(user=user)
@ -137,3 +155,35 @@ class TestBirdingCSVImportModel:
imp.refresh_from_db()
assert imp.process_count == 1
assert imp.processed_finished is not None
def test_record_error_on_bad_csv(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Could not parse date/time" in errors[0]
def test_record_error_on_bad_location(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Skipping rows with no location" in errors[0]

View File

@ -26,5 +26,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
@admin.register(BirdingCSVImport)
class BirdingCSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
ordering = ("-created",)

View File

@ -2,7 +2,9 @@ import csv
import logging
import re
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import timedelta
from dateutil import parser
from django.contrib.auth import get_user_model
@ -35,11 +37,12 @@ def parse_coords(location_str):
def parse_timestamp(date_str, time_str):
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
dt_str = f"{date_str} {time_str}".strip()
dt = parser.parse(dt_str)
return dt
except (ValueError, TypeError):
try:
dt = datetime.strptime(date_str, "%B %d, %Y")
dt = parser.parse(date_str)
return dt
except (ValueError, TypeError):
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
@ -61,7 +64,7 @@ def parse_int(value):
return None
def import_birding_csv(file_path, user_id):
def import_birding_csv(file_path, user_id, record_error=None):
user = User.objects.get(id=user_id)
new_scrobbles = []
@ -80,11 +83,17 @@ def import_birding_csv(file_path, user_id):
for (location_str, date_str, time_str), sighting_rows in groups.items():
if not location_str:
logger.warning("Skipping rows with no location")
msg = "Skipping rows with no location"
logger.warning(msg)
if record_error:
record_error(msg)
continue
timestamp = parse_timestamp(date_str, time_str)
if not timestamp:
msg = f"Could not parse date/time: {date_str} {time_str}"
if record_error:
record_error(msg)
continue
timestamp = user.profile.get_timestamp_with_tz(timestamp)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("birds", "0002_birdingcsvimport"),
]
operations = [
migrations.AddField(
model_name="birdingcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
@cached_property
def bird_list(self) -> str:
if self.birds:
return ", ".join(
[BirdSightingEntry(**b).__str__() for b in self.birds]
)
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
return ""
def as_html(self) -> str:
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.area:
html_parts.append(
f'<div class="birding-area">Area: {self.area}</div>'
)
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
if self.party_size:
html_parts.append(
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.guide:
html_parts.append(
f'<div class="birding-guide">Guide: {self.guide}</div>'
)
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
if self.duration_minutes:
html_parts.append(
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
class BirdingLocation(ScrobblableMixin):
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self):
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
@property
def subtitle(self):
return ""
return self.geo_location
@property
def strings(self) -> ScrobblableConstants:
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
error_log = models.TextField(**BNULL)
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
class Meta:
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = (
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
)
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def record_error(self, error_message):
log_line = f"{timezone.now().isoformat()}: {error_message}"
if self.error_log:
self.error_log += "\n" + log_line
else:
self.error_log = log_line
self.save(update_fields=["error_log"])
def scrobbles(self):
from scrobbles.models import Scrobble
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
from birds.importer import import_birding_csv
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_birding_csv(
self.upload_file_path, self.user_id, record_error=self.record_error
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()

View File

@ -266,8 +266,9 @@ class BoardGame(ScrobblableMixin):
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self):
return self.title
@property
def subtitle(self) -> str:
return self.publisher
def get_absolute_url(self):
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})

View File

@ -173,11 +173,7 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}"
return f"{self.title} - {self.subtitle}"
def save(self, *args, **kwargs):
if self.pages:
@ -188,7 +184,12 @@ class Book(LongPlayScrobblableMixin):
@property
def subtitle(self):
return f" by {self.author}"
subtitle = self.author
if self.issue_number and "Issue" not in str(self.title):
subtitle += " - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
subtitle += " - Volume {self.volume_number}"
return subtitle
@property
def strings(self) -> ScrobblableConstants:

View File

@ -0,0 +1,127 @@
import csv
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def _get_source(raw_data):
if "Artist" in raw_data:
return "Jellyfin"
if "artist" in raw_data:
return "Mopidy"
return None
def _get_raw_values(raw_data, source):
if source == "Jellyfin":
return raw_data.get("Artist", ""), raw_data.get("Album", "")
return raw_data.get("artist", ""), raw_data.get("album", "")
def _normalize(name):
return name.strip().casefold()
def _artist_mismatch(raw_artist, track_artist_names):
if not raw_artist or not track_artist_names:
return False
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
raw = _normalize(raw_artist)
if raw in track_names:
return False
if raw == _normalize(track_artist_names):
return False
return True
def _album_mismatch(raw_album, track_album_name):
if not raw_album or not track_album_name:
return False
return _normalize(raw_album) != _normalize(track_album_name)
class Command(BaseCommand):
help = (
"Outputs a CSV of track IDs where raw metadata from scrobble logs "
"does not match the track's stored artists or album"
)
def add_arguments(self, parser):
parser.add_argument(
"--file-path",
type=str,
default="/tmp/metadata-report.csv",
help="Output CSV file path (default: /tmp/metadata-report.csv)",
)
def handle(self, *args, **options):
from scrobbles.models import Scrobble
file_path = options["file_path"]
qs = (
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
.exclude(log__isnull=True)
.exclude(log={})
.select_related("track__album")
.prefetch_related("track__artists")
.iterator()
)
rows = []
for scrobble in qs:
track = scrobble.track
if not track:
continue
raw_data = scrobble.log.get("raw_data")
if not raw_data:
continue
source = _get_source(raw_data)
if not source:
continue
raw_artist, raw_album = _get_raw_values(raw_data, source)
if not raw_artist and not raw_album:
continue
track_artist_names = " / ".join(
track.artists.all().values_list("name", flat=True)
)
track_album_name = track.album.name if track.album else ""
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch(
raw_album, track_album_name
):
rows.append(
{
"track_id": track.id,
"track_artist_name": track_artist_names,
"track_album_name": track_album_name,
"raw_artist": raw_artist,
"raw_album": raw_album,
"source": source,
}
)
fieldnames = [
"track_id",
"track_artist_name",
"track_album_name",
"raw_artist",
"raw_album",
"source",
]
with open(file_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
self.stdout.write(
self.style.SUCCESS(
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
)
)

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0036_userprofile_default_scrobble_visibility"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="private",
max_length=10,
),
),
]

View File

@ -9,6 +9,11 @@ from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES
VISIBILITY_CHOICES = (
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
)
User = get_user_model()
BNULL = {"blank": True, "null": True}
@ -79,6 +84,12 @@ class UserProfile(TimeStampedModel):
enable_public_widgets = models.BooleanField(default=False)
widget_custom_css = models.TextField(**BNULL)
default_scrobble_visibility = models.CharField(
max_length=10,
choices=VISIBILITY_CHOICES,
default="private",
)
home_scrobble_limit = models.IntegerField(default=20)
weigh_in_units = models.CharField(

View File

@ -10,6 +10,7 @@ from scrobbles.models import (
RetroarchImport,
ScaleCSVImport,
Scrobble,
ShareViewLog,
TrailGPXImport,
)
from scrobbles.mixins import Genre
@ -20,6 +21,7 @@ class ScrobbleInline(admin.TabularInline):
extra = 0
raw_id_fields = (
"video",
"channel",
"podcast_episode",
"track",
"video_game",
@ -30,6 +32,7 @@ class ScrobbleInline(admin.TabularInline):
"board_game",
"geo_location",
"task",
"puzzle",
"mood",
"brick_set",
"trail",
@ -54,52 +57,44 @@ class ImportBaseAdmin(admin.ModelAdmin):
"process_count",
"processed_finished",
"processing_started",
"error_log",
)
ordering = ("-created",)
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
...
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin): ...
@admin.register(LastFmImport)
class LastFmImportAdmin(ImportBaseAdmin):
...
class LastFmImportAdmin(ImportBaseAdmin): ...
@admin.register(KoReaderImport)
class KoReaderImportAdmin(ImportBaseAdmin):
...
class KoReaderImportAdmin(ImportBaseAdmin): ...
@admin.register(RetroarchImport)
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
@admin.register(BGStatsImport)
class BGStatsImportAdmin(ImportBaseAdmin):
...
class BGStatsImportAdmin(ImportBaseAdmin): ...
@admin.register(EBirdCSVImport)
class EBirdCSVImportAdmin(ImportBaseAdmin):
...
class EBirdCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(ScaleCSVImport)
class ScaleCSVImportAdmin(ImportBaseAdmin):
...
class ScaleCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(TrailGPXImport)
class TrailGPXImportAdmin(ImportBaseAdmin):
...
class TrailGPXImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre)
@ -122,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"in_progress",
"is_paused",
"played_to_completion",
"visibility",
"user",
)
raw_id_fields = (
@ -149,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"is_paused",
"in_progress",
"media_type",
"visibility",
"long_play_complete",
"source",
"timezone",
@ -167,6 +164,14 @@ class ScrobbleAdmin(admin.ModelAdmin):
return qs
@admin.register(ShareViewLog)
class ShareViewLogAdmin(admin.ModelAdmin):
list_display = ("scrobble", "ip_address", "created")
list_filter = ("created",)
date_hierarchy = "created"
raw_id_fields = ("scrobble",)
@admin.register(FavoriteMedia)
class FavoriteMediaAdmin(admin.ModelAdmin):
list_display = ("user", "media_type", "sent_to_mopidy", "created")

View File

@ -1,6 +1,7 @@
import re
from rest_framework import serializers
from scrobbles.constants import Visibility
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,

View File

@ -1,6 +1,8 @@
from logging import getLogger
from rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from scrobbles.api.serializers import (
AudioScrobblerTSVImportSerializer,
KoReaderImportSerializer,
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
@action(detail=True, methods=["post"])
def regenerate_share_token(self, request, uuid=None):
scrobble = self.get_object()
scrobble.regenerate_share_token()
return Response({"share_url": scrobble.get_share_url()})
class KoReaderImportViewSet(viewsets.ModelViewSet):
queryset = KoReaderImport.objects.all().order_by("-created")

View File

@ -1,6 +1,12 @@
from django.db import models
from enum import Enum
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
class Visibility(models.TextChoices):
PUBLIC = "public", "Public"
SHARED = "shared", "Shared"
PRIVATE = "private", "Private"
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = {

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0089_favoritemedia"),
]
operations = [
migrations.AddField(
model_name="audioscrobblertsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="bgstatsimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="ebirdcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="koreaderimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="lastfmimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="retroarchimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="scalecsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="trailgpximport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_token",
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
),
migrations.AddField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,29 @@
from uuid import uuid4
from django.db import migrations
def backfill_share_token(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
batch = []
for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(
chunk_size=500
):
scrobble.share_token = uuid4()
batch.append(scrobble)
if batch:
Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),
]
operations = [
migrations.RunPython(
backfill_share_token,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.29 on 2026-06-09 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0092_backfill_visibility_and_share_token"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="share_token",
),
migrations.AddField(
model_name="scrobble",
name="share_token_version",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.29 on 2026-06-09 16:24
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0093_remove_scrobble_share_token_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_view_count",
field=models.PositiveIntegerField(default=0),
),
migrations.AlterField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="private",
max_length=10,
),
),
migrations.CreateModel(
name="ShareViewLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("user_agent", models.TextField(blank=True, null=True)),
("referrer", models.URLField(blank=True, max_length=2048, null=True)),
(
"scrobble",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="share_views",
to="scrobbles.scrobble",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -65,6 +65,9 @@ class ScrobblableMixin(TimeStampedModel):
class Meta:
abstract = True
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"
@property
def run_time_seconds(self) -> int:
run_time = 900

View File

@ -18,6 +18,8 @@ from bricksets.models import BrickSet
from charts.utils import build_charts
from dataclass_wizard.errors import ParseError
from django.conf import settings
from scrobbles.constants import Visibility
from scrobbles.sqids import encode_scrobble_share
from django.contrib.auth import get_user_model
from django.core.files import File
from django.db import models
@ -74,6 +76,7 @@ class BaseFileImportMixin(TimeStampedModel):
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
error_log = models.TextField(**BNULL)
class Meta:
abstract = True
@ -158,6 +161,14 @@ class BaseFileImportMixin(TimeStampedModel):
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def record_error(self, error_message):
log_line = f"{timezone.now().isoformat()}: {error_message}"
if self.error_log:
self.error_log += "\n" + log_line
else:
self.error_log = log_line
self.save(update_fields=["error_log"])
@property
def upload_file_path(self):
raise NotImplementedError
@ -213,9 +224,14 @@ class KoReaderImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class AudioScrobblerTSVImport(BaseFileImportMixin):
@ -255,10 +271,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class ScaleCSVImport(BaseFileImportMixin):
@ -297,9 +317,14 @@ class ScaleCSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class TrailGPXImport(BaseFileImportMixin):
@ -337,11 +362,16 @@ class TrailGPXImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_trail_gpx(
self.upload_file_path, self.user.id, self.original_filename
)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_trail_gpx(
self.upload_file_path, self.user.id, self.original_filename
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class LastFmImport(BaseFileImportMixin):
@ -391,11 +421,14 @@ class LastFmImport(BaseFileImportMixin):
last_processed = last_import.processed_finished
self.mark_started()
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class RetroarchImport(BaseFileImportMixin):
@ -426,43 +459,48 @@ class RetroarchImport(BaseFileImportMixin):
logger.info(f"You told me to force import from Retroarch")
self.mark_started()
scrobbles = None
try:
if self.lrtl_file:
import os
import tempfile
import zipfile
if self.lrtl_file:
import os
import tempfile
import zipfile
tmpdir = tempfile.mkdtemp()
try:
zip_path = os.path.join(tmpdir, "archive.zip")
with open(zip_path, "wb") as f:
f.write(self.lrtl_file.read())
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmpdir)
os.unlink(zip_path)
scrobbles = retroarch.import_retroarch_lrtl_files(
tmpdir + "/",
self.user.id,
)
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
else:
if not self.user.profile.retroarch_path:
logger.info(
"Trying to import Retroarch logs, but user has no retroarch_path configured"
)
self.mark_finished()
return
tmpdir = tempfile.mkdtemp()
try:
zip_path = os.path.join(tmpdir, "archive.zip")
with open(zip_path, "wb") as f:
f.write(self.lrtl_file.read())
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmpdir)
os.unlink(zip_path)
scrobbles = retroarch.import_retroarch_lrtl_files(
tmpdir + "/",
self.user.profile.retroarch_path,
self.user.id,
)
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
else:
if not self.user.profile.retroarch_path:
logger.info(
"Tying to import Retroarch logs, but user has no retroarch_path configured"
)
self.mark_finished()
return
scrobbles = retroarch.import_retroarch_lrtl_files(
self.user.profile.retroarch_path,
self.user.id,
)
self.record_log(scrobbles)
self.mark_finished()
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class BGStatsImport(BaseFileImportMixin):
@ -499,17 +537,21 @@ class BGStatsImport(BaseFileImportMixin):
return
self.mark_started()
try:
import json
import json
from scrobbles.scrobblers import email_scrobble_board_game
from scrobbles.scrobblers import email_scrobble_board_game
with open(self.upload_file_path, "r", encoding="utf-8") as f:
parsed_json = json.load(f)
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
with open(self.upload_file_path, "r", encoding="utf-8") as f:
parsed_json = json.load(f)
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class EBirdCSVImport(BaseFileImportMixin):
@ -554,9 +596,16 @@ class EBirdCSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_birding_csv(
self.upload_file_path, self.user_id, record_error=self.record_error
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class ScrobbleQuerySet(models.QuerySet):
@ -586,6 +635,18 @@ class ScrobbleQuerySet(models.QuerySet):
)
class ShareViewLog(TimeStampedModel):
scrobble = models.ForeignKey(
"Scrobble", on_delete=models.CASCADE, related_name="share_views"
)
ip_address = models.GenericIPAddressField(**BNULL)
user_agent = models.TextField(**BNULL)
referrer = models.URLField(max_length=2048, **BNULL)
def __str__(self):
return f"View of {self.scrobble} at {self.created}"
class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user."""
@ -647,6 +708,14 @@ class Scrobble(TimeStampedModel):
media_type = models.CharField(
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
)
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.PRIVATE,
db_index=True,
)
share_token_version = models.PositiveIntegerField(default=0)
share_view_count = models.PositiveIntegerField(default=0)
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
# Time keeping
@ -828,6 +897,16 @@ class Scrobble(TimeStampedModel):
self.save(update_fields=["uuid"])
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
def get_share_url(self):
if self.visibility == Visibility.PRIVATE:
return None
sqid = encode_scrobble_share(self.id, self.share_token_version)
return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
def regenerate_share_token(self):
self.share_token_version += 1
self.save(update_fields=["share_token_version"])
def push_to_archivebox(self):
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
self.media_obj.push_to_archivebox

View File

@ -0,0 +1,36 @@
from sqids import Sqids
_sqids = None
def _make_alphabet() -> str:
import hashlib
from django.conf import settings
digest = hashlib.sha256(settings.SECRET_KEY.encode()).hexdigest()
base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seed = int(digest[:16], 16)
shuffled = list(base)
for i in range(len(shuffled) - 1, 0, -1):
seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF
j = seed % (i + 1)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
return "".join(shuffled)
def get_sqids() -> Sqids:
global _sqids
if _sqids is None:
_sqids = Sqids(
alphabet=_make_alphabet(),
min_length=6,
)
return _sqids
def encode_scrobble_share(scrobble_id: int, version: int) -> str:
return get_sqids().encode([scrobble_id, version])
def decode_scrobble_share(sqid: str) -> list[int] | None:
return get_sqids().decode(sqid)

View File

@ -153,11 +153,32 @@ urlpatterns = [
name="long-plays",
),
path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),
path("explore/", views.ScrobbleExploreView.as_view(), name="explore"),
path(
"shared/<str:sqid>/",
views.ScrobbleShareView.as_view(),
name="shared-detail",
),
path(
"scrobbles/<slug:uuid>/",
views.ScrobbleDetailView.as_view(),
name="detail",
),
path(
"scrobbles/<slug:uuid>/regenerate-share-token/",
views.RegenerateShareTokenView.as_view(),
name="regenerate-share-token",
),
path(
"scrobbles/<slug:uuid>/change-visibility/",
views.ChangeVisibilityView.as_view(),
name="change-visibility",
),
path(
"scrobbles/<slug:uuid>/share-analytics/",
views.ScrobbleShareAnalyticsView.as_view(),
name="share-analytics",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
views.add_to_mopidy_queue,

View File

@ -14,7 +14,7 @@ from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q, Sum
from django.db.models import Count, F, Max, Q, Sum
from django.db.models.query import QuerySet
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
@ -38,7 +38,7 @@ from django.utils import timezone
from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic import DetailView, FormView, TemplateView, View
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from moods.models import Mood
@ -72,6 +72,8 @@ from scrobbles.constants import (
)
from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.constants import Visibility
from scrobbles.sqids import decode_scrobble_share
from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
@ -83,6 +85,7 @@ from scrobbles.models import (
ScaleCSVImport,
Scrobble,
ScrobbleQuerySet,
ShareViewLog,
TrailGPXImport,
)
from scrobbles.scrobblers import *
@ -184,7 +187,7 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
scrobbles = []
if not self.request.user.is_anonymous:
if not self.request.user.is_anonymous and hasattr(self.object, "scrobble_set"):
scrobbles = self.object.scrobble_set.filter(
user=self.request.user
).order_by("-timestamp")
@ -439,8 +442,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
full_qs = getattr(self, "_full_queryset", None)
if full_qs is not None and getattr(self, "tag_list", []):
total = (
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
or 0
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"] or 0
)
ctx["total_time_seconds"] = total
return ctx
@ -1137,6 +1139,15 @@ class ScrobbleDetailView(DetailView):
slug_url_kwarg = "uuid"
paginate_by = 100
def get_object(self, queryset=None):
scrobble = super().get_object(queryset=queryset)
user = self.request.user
if scrobble.visibility == Visibility.PUBLIC:
return scrobble
if user.is_authenticated and scrobble.user == user:
return scrobble
raise Http404
def get_form_class(self):
return self.object.media_obj.logdata_cls().form()
@ -1245,8 +1256,7 @@ class ScrobbleDetailView(DetailView):
track=media_obj, user=self.object.user
).order_by("-timestamp")[:20]
context["has_mopidy_uri"] = any(
(s.log or {}).get("raw_data", {}).get("mopidy_uri")
for s in scrobbles
(s.log or {}).get("raw_data", {}).get("mopidy_uri") for s in scrobbles
)
else:
context["has_mopidy_uri"] = False
@ -1254,6 +1264,93 @@ class ScrobbleDetailView(DetailView):
return context
class ScrobbleShareView(TemplateView):
template_name = "scrobbles/scrobble_share.html"
def get_object(self):
sqid = self.kwargs.get("sqid")
decoded = decode_scrobble_share(sqid)
if not decoded or len(decoded) != 2:
raise Http404
scrobble_id, version = decoded
scrobble = get_object_or_404(Scrobble, id=scrobble_id)
if scrobble.share_token_version != version:
raise Http404
if scrobble.visibility not in (Visibility.PUBLIC, Visibility.SHARED):
raise Http404
Scrobble.objects.filter(id=scrobble.id).update(
share_view_count=F("share_view_count") + 1
)
scrobble.refresh_from_db(fields=["share_view_count"])
ShareViewLog.objects.create(
scrobble=scrobble,
ip_address=self.request.META.get("REMOTE_ADDR"),
user_agent=self.request.META.get("HTTP_USER_AGENT", "")[:500],
referrer=self.request.META.get("HTTP_REFERER", ""),
)
return scrobble
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
scrobble = self.get_object()
context["object"] = scrobble
context["log_form"] = None
context["related_scrobbles"] = Scrobble.objects.none()
context["has_mopidy_uri"] = False
if self.request.user.is_authenticated:
media_type = scrobble.media_type
fk_field = ScrobbleDetailView.MEDIA_FK_MAP.get(media_type)
media_obj = scrobble.media_obj
if fk_field and media_obj:
context["is_favorited"] = FavoriteMedia.objects.filter(
user=self.request.user, **{fk_field: media_obj}
).exists()
return context
class ScrobbleExploreView(ListView):
model = Scrobble
paginate_by = 100
template_name = "scrobbles/scrobble_explore.html"
queryset = Scrobble.objects.filter(visibility=Visibility.PUBLIC).order_by(
"-timestamp"
)
class RegenerateShareTokenView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
scrobble.regenerate_share_token()
return redirect(scrobble.get_absolute_url())
class ChangeVisibilityView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
visibility = request.POST.get("visibility")
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
return redirect(scrobble.get_absolute_url())
scrobble.visibility = visibility
scrobble.save(update_fields=["visibility"])
return redirect(scrobble.get_absolute_url())
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
template_name = "scrobbles/scrobble_share_analytics.html"
def get_queryset(self):
return Scrobble.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
scrobble = self.object
context["share_views"] = scrobble.share_views.order_by("-created")[:50]
return context
class BaseEmbeddableWidget(TemplateView):
template_name = "scrobbles/embeddable_top_media.html"

View File

@ -123,17 +123,17 @@ class SportEvent(ScrobblableMixin):
super(SportEvent, self).save(*args, **kwargs)
def __str__(self) -> str:
league = self.league
if self.league and self.league.abbreviation_str:
league = self.league.abbreviation_str
return f"{self.title} - {league}"
return f"{self.title} - {self.subtitle}"
def get_absolute_url(self):
return reverse("sports:event_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self) -> str:
return self.comp_str
league = self.league
if self.league and self.league.abbreviation_str:
league = self.league.abbreviation_str
return f"{league} {self.get_event_type_display()}"
@property
def comp_str(self) -> str:

View File

@ -163,12 +163,9 @@ class VideoGame(LongPlayScrobblableMixin):
platforms = models.ManyToManyField(VideoGamePlatform)
retroarch_name = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.title
@property
def subtitle(self):
return f" On {self.platforms.first()}"
return f"{self.platforms.first()}"
@property
def strings(self) -> ScrobblableConstants:

View File

@ -71,6 +71,7 @@ class VideoMetadata:
twitch_id: Optional[str] = "",
base_run_time_seconds: int = 900,
):
self.title = ""
self.imdb_id = imdb_id
self.youtube_id = youtube_id
self.twitch_id = twitch_id

View File

@ -20,6 +20,7 @@ from scrobbles.mixins import (
)
from taggit.managers import TaggableManager
from videos.metadata import VideoMetadata
from videos.sources.omdb import lookup_video_from_omdb
from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube
@ -224,6 +225,8 @@ class Series(TimeStampedModel):
def is_episode_playing(self, user_id: int) -> bool:
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
if not last_scrobble:
return False
return not last_scrobble.played_to_completion
def fix_metadata(self, force_update=False):
@ -255,9 +258,20 @@ class Series(TimeStampedModel):
logger.info("Series not created and overwrite=False, returning")
return series
vdict, _, cover, genres = lookup_video_from_tmdb(
imdb_id
).as_dict_with_cover_and_genres()
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for series {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for series {imdb_id} from TMDB or OMDB")
return series
vdict, _, cover, genres = metadata.as_dict_with_cover_and_genres()
vdict.pop("video_type")
vdict["name"] = vdict.pop("title")
@ -406,6 +420,84 @@ class Video(ScrobblableMixin):
fname = f"{self.title}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
def fix_metadata(self, force_update: bool = False) -> None:
if self.video_type == self.VideoType.YOUTUBE and self.youtube_id:
vdict, _, cover, genres = lookup_video_from_youtube(
self.youtube_id
).as_dict_with_cover_and_genres()
for k, v in vdict.items():
setattr(self, k, v)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
if self.video_type == self.VideoType.TWITCH and self.twitch_id:
from videos.sources.twitch import lookup_video_from_twitch
metadata = lookup_video_from_twitch(self.twitch_id)
self.title = metadata.title or f"Twitch VOD {self.twitch_id}"
self.overview = metadata.overview
self.base_run_time_seconds = metadata.base_run_time_seconds
if metadata.upload_date:
self.upload_date = metadata.upload_date
if metadata.year:
self.year = metadata.year
self.video_type = Video.VideoType.TWITCH
if metadata.channel_id:
from videos.models import Channel
self.channel = Channel.objects.filter(
id=metadata.channel_id
).first()
self.save()
if metadata.cover_url:
self.save_image_from_url(metadata.cover_url)
return
if self.imdb_id:
try:
metadata = lookup_video_from_tmdb(self.imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {self.imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(self.imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
return
vdict, series_id, cover, genres = (
metadata.as_dict_with_cover_and_genres()
)
for k, v in vdict.items():
setattr(self, k, v)
if series_id:
self.tv_series = Series.find_or_create(imdb_id=series_id)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
logger.warning(
f"No metadata source available for {self} (type={self.video_type})"
)
@classmethod
def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
video, created = cls.objects.get_or_create(youtube_id=youtube_id)
@ -432,9 +524,20 @@ class Video(ScrobblableMixin):
if not created and not overwrite:
return video
vdict, series_id, cover, genres = lookup_video_from_tmdb(
imdb_id
).as_dict_with_cover_and_genres()
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {imdb_id} from TMDB or OMDB")
return video
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
if created or overwrite:
for k, v in vdict.items():

View File

@ -0,0 +1,83 @@
import logging
import re
from typing import Optional
import requests
from django.conf import settings
from videos.metadata import VideoMetadata, VideoType
logger = logging.getLogger(__name__)
OMDB_API_KEY = getattr(settings, "OMDB_API_KEY", "")
OMDB_URL = "https://www.omdbapi.com/"
RUNTIME_RE = re.compile(r"(\d+)\s*min")
def lookup_video_from_omdb(imdb_id: str) -> Optional[VideoMetadata]:
if not imdb_id.startswith("tt"):
imdb_id = f"tt{imdb_id}"
if not OMDB_API_KEY:
logger.warning("No OMDB API key configured")
return None
params = {"apikey": OMDB_API_KEY, "i": imdb_id, "plot": "full"}
try:
response = requests.get(OMDB_URL, params=params)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"OMDB API error for {imdb_id}: {e}")
return None
if data.get("Response") == "False":
logger.info(f"OMDB no result for {imdb_id}: {data.get('Error')}")
return None
metadata = VideoMetadata(imdb_id=imdb_id)
metadata.title = data.get("Title")
metadata.plot = data.get("Plot")
metadata.overview = data.get("Plot")
raw_year = data.get("Year")
if raw_year and raw_year.isdigit():
metadata.year = int(raw_year)
raw_rating = data.get("imdbRating")
if raw_rating and raw_rating != "N/A":
metadata.imdb_rating = raw_rating
raw_cover = data.get("Poster")
if raw_cover and raw_cover != "N/A":
metadata.cover_url = raw_cover
raw_runtime = data.get("Runtime")
if raw_runtime:
match = RUNTIME_RE.match(raw_runtime)
if match:
metadata.base_run_time_seconds = int(match.group(1)) * 60
media_type = data.get("Type")
if media_type == "movie":
metadata.video_type = VideoType.MOVIE.value
elif media_type in ("series", "episode"):
metadata.video_type = VideoType.TV_EPISODE.value
if media_type == "episode":
raw_season = data.get("Season")
if raw_season and raw_season != "N/A":
metadata.season_number = int(raw_season)
raw_episode = data.get("Episode")
if raw_episode and raw_episode != "N/A":
metadata.episode_number = int(raw_episode)
series_imdb_id = data.get("seriesID")
if series_imdb_id and series_imdb_id != "N/A":
metadata.tv_series_imdb_id = series_imdb_id
raw_genres = data.get("Genre")
if raw_genres:
metadata.genres = [g.strip() for g in raw_genres.split(",") if g.strip()]
return metadata

View File

@ -3,14 +3,11 @@ import logging
import pendulum
from django.conf import settings
from themoviedb import TMDb
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
from tmdbv3api import TV, Movie
from videos.metadata import VideoMetadata, VideoType
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
tmdb_direct = TMDb_direct()
tmdb_direct.api_key = TMDB_KEY
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
@ -36,7 +33,7 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
video_metadata = VideoMetadata(imdb_id=imdb_id)
media = None
show = None
show_data = None
if len(tmdb_result.movie_results) > 0:
media = Movie().details(tmdb_result.movie_results[0].id)
video_metadata.video_type = VideoType.MOVIE.value

View File

@ -32,7 +32,11 @@ class SeriesDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView
context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
next_episode_id = self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
next_episode_id = ""
if self.object.last_scrobbled_episode(user_id):
next_episode_id = (
self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
)
if self.object.is_episode_playing(user_id):
next_episode_id = ""
if next_episode_id:

View File

@ -60,6 +60,7 @@ THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
PODCASTINDEX_API_KEY = os.getenv("VROBBLER_PODCASTINDEX_API_KEY", "")
PODCASTINDEX_API_SECRET = os.getenv("VROBBLER_PODCASTINDEX_API_SECRET", "")
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
OMDB_API_KEY = os.getenv("VROBBLER_OMDB_API_KEY", "")
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID")

View File

@ -50,7 +50,11 @@
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
{% if object.media_obj.get_absolute_url %}
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
{{ object.media_obj.title }}
{% if object.media_obj.get_absolute_url %}</a>
{% endif %}
{% if user.is_authenticated and object.media_obj %}
<button id="favorite-btn"
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
@ -62,10 +66,52 @@
</button>
{% endif %}
</h1>
<h2>{{ object.media_obj.subtitle }}</h2>
<p>
{% if object.media_type == "SportEvent" %}
{% for team in object.media_obj.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.title %}
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if user.is_authenticated and object.user == user %}
<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">
<span class="badge
{% if object.visibility == 'public' %}bg-success
{% elif object.visibility == 'shared' %}bg-warning text-dark
{% else %}bg-secondary
{% endif %}">
{{ object.get_visibility_display }}
</span>
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
{% csrf_token %}
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
<option value="shared" {% if object.visibility == 'shared' %}selected{% endif %}>Shared (link)</option>
<option value="public" {% if object.visibility == 'public' %}selected{% endif %}>Public (explore)</option>
</select>
</form>
{% if object.visibility == 'shared' and object.get_share_url %}
<span class="small text-muted">Share link:</span>
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
</form>
{% if object.share_view_count %}
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
{% endif %}
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
{% endif %}
</div>
{% endif %}
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
{% csrf_token %}

View File

@ -0,0 +1,140 @@
{% 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">
<h1 class="h2">Explore Public Scrobbles</h1>
</div>
<p class="text-muted">Recent public scrobbles from all users.</p>
<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">User</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>
<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>{{ scrobble.user.username }}</td>
<td>
{% if scrobble.playback_position_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5">No public 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 }}">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 }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</main>
{% endblock %}

View File

@ -0,0 +1,200 @@
{% extends "base_list.html" %}
{% load form_tags %}
{% load mathfilters %}
{% load naturalduration %}
{% load static %}
{% block title %}{{object.name}}{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
dl { border:none; }
dt {
background: #333;
color: #fff;
}
dd {
float:left;
margin: 2px;
padding: 4px;
min-height: 1em;
border: none;
}
#map {
height: 400px;
border-radius: 4px;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="alert alert-info">
Shared via link
</div>
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_obj.get_absolute_url %}
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
{{ object.media_obj.title }}
{% if object.media_obj.get_absolute_url %}</a>
{% endif %}
</h1>
<h2>{{ object.media_obj.subtitle }}</h2>
<p>
{% if object.media_type == "SportEvent" %}
{% for team in object.media_obj.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.title %}
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if object.media_type == "Track" %}
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
{% endif %}
{% if object.media_type == "Task" and object.log.weight %}
<div class="mb-3">
<dl class="row" style="max-width: 400px;">
<dt class="col-sm-5">Weight</dt>
<dd class="col-sm-7">{{ object.log.weight }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% if object.log.body_fat %}
<dt class="col-sm-5">Body Fat</dt>
<dd class="col-sm-7">{{ object.log.body_fat }}%</dd>
{% endif %}
{% if object.log.bmi %}
<dt class="col-sm-5">BMI</dt>
<dd class="col-sm-7">{{ object.log.bmi }}</dd>
{% endif %}
{% if object.log.muscle %}
<dt class="col-sm-5">Muscle</dt>
<dd class="col-sm-7">{{ object.log.muscle }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.bone %}
<dt class="col-sm-5">Bone</dt>
<dd class="col-sm-7">{{ object.log.bone }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.water %}
<dt class="col-sm-5">Water</dt>
<dd class="col-sm-7">{{ object.log.water }}%</dd>
{% endif %}
{% if object.log.visceral_fat %}
<dt class="col-sm-5">Visceral Fat</dt>
<dd class="col-sm-7">{{ object.log.visceral_fat }}</dd>
{% endif %}
{% if object.log.waist %}
<dt class="col-sm-5">Waist</dt>
<dd class="col-sm-7">{{ object.log.waist }} {% if object.log.unit_type == "imperial" %}in{% else %}cm{% endif %}</dd>
{% endif %}
{% if object.log.lbm %}
<dt class="col-sm-5">Lean Mass</dt>
<dd class="col-sm-7">{{ object.log.lbm }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.calories %}
<dt class="col-sm-5">Calories</dt>
<dd class="col-sm-7">{{ object.log.calories }}</dd>
{% endif %}
{% if object.log.comment %}
<dt class="col-sm-5">Comment</dt>
<dd class="col-sm-7">{{ object.log.comment }}</dd>
{% endif %}
</dl>
</div>
{% endif %}
{% if object.media_type == "Task" and object.logdata.description %}
<p>{{ object.logdata.description }}</p>
{% endif %}
{% if object.media_type == "Trail" and object.gpx_file %}
<div class="mb-3">
<div id="map"></div>
</div>
{% endif %}
<p>
Tags:
{% if object.tags.all %}
{% for tag in object.tags.all %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endfor %}
{% else %}
untagged
{% endif %}
</p>
{% with notes_html=object.logdata.notes_as_html %}
{% if notes_html %}
<div class="mb-3">
<h4>Notes</h4>
<span class="badge fs-8
{% if sentiment.compound >= 0.5 %}bg-success
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
{% elif sentiment.compound > -0.05 %}bg-secondary
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
{% else %}bg-danger
{% endif %}">
{% if sentiment.compound >= 0.5 %}Positive
{% elif sentiment.compound >= 0.05 %}Slightly positive
{% elif sentiment.compound > -0.05 %}Neutral
{% elif sentiment.compound > -0.5 %}Slightly negative
{% else %}Negative
{% endif %}
</span>
<div class="notes-list">
{{ notes_html|safe }}
</div>
</div>
{% endif %}
{% endwith %}
{% with sentiment=object.log.sentiment %}
{% if sentiment %}
<div class="mb-3">
</div>
{% endif %}
{% endwith %}
{% if object.logdata.avg_seconds_per_page %}
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
{% endif %}
{% if object.media_type == "BoardGame" and object.logdata.as_html %}
<div class="mb-3">
<h5>Game Details</h5>
{{ object.logdata.as_html|safe }}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{% if object.media_type == "Trail" and object.gpx_file %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
<script>
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'origin'
}).addTo(map);
var gpx = new L.GPX("{{ object.gpx_file.url|escapejs }}", {
async: true,
polyline_options: { color: '#e74c3c' }
});
gpx.on('loaded', function(e) {
map.fitBounds(e.target.getBounds());
});
gpx.addTo(map);
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,68 @@
{% extends "base_list.html" %}
{% load naturalduration %}
{% block title %}Share Analytics for {{ object.media_obj.title }}{% endblock %}
{% block lists %}
<div class="row">
<h1>Share Analytics</h1>
<h2 class="text-muted">{{ object.media_obj.title }}</h2>
<div class="mb-3">
<span class="badge
{% if object.visibility == 'public' %}bg-success
{% elif object.visibility == 'shared' %}bg-warning text-dark
{% else %}bg-secondary
{% endif %}">
{{ object.get_visibility_display }}
</span>
<span class="ms-2"><strong>{{ object.share_view_count }}</strong> view{{ object.share_view_count|pluralize }}</span>
</div>
{% if object.get_share_url %}
<div class="mb-3">
<span class="text-muted">Share link:</span>
<code class="small">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
</div>
{% endif %}
<h3 class="mt-4">View History</h3>
{% if share_views %}
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">IP Address</th>
<th scope="col">Referrer</th>
<th scope="col">User Agent</th>
</tr>
</thead>
<tbody>
{% for view in share_views %}
<tr>
<td>{{ view.created|date:"M d, Y H:i" }}</td>
<td><code>{{ view.ip_address|default:"-" }}</code></td>
<td class="text-truncate" style="max-width: 200px;">
{% if view.referrer %}
<a href="{{ view.referrer }}" target="_blank" rel="noopener">{{ view.referrer }}</a>
{% else %}-{% endif %}
</td>
<td class="text-truncate" style="max-width: 300px;">
<span title="{{ view.user_agent }}">{{ view.user_agent|default:"-"|truncatechars:60 }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No views yet. Share the link to see who visits.</p>
{% endif %}
<a href="{{ object.get_absolute_url }}" class="btn btn-secondary">Back to scrobble</a>
</div>
{% endblock %}

View File

@ -1,13 +1,16 @@
{% extends "base_detail.html" %}
{% block title %}{{object}}{% endblock %}
{% block title %}{{object.title}}{% endblock %}
{% block details %}
<h2>{{object.subtitle}}</h2>
<h2>{{object.league}} {{object.get_event_type_display}}</h2>
<div class="row">
<h2>{{object.tv_series}}</h2>
<div class="col-md">
{% for team in object.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
<hr />
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">