[music] Fix historical LFM imports
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 45s

This commit is contained in:
2026-05-24 22:11:00 -04:00
parent 8a419c7bbc
commit 4e1c3ffbf0
7 changed files with 262 additions and 101 deletions

View File

@ -628,6 +628,7 @@ class Track(ScrobblableMixin):
artist_name: str = "", artist_name: str = "",
album_name: str = "", album_name: str = "",
run_time_seconds: int | None = None, run_time_seconds: int | None = None,
mbid: str | None = None,
enrich: bool = False, enrich: bool = False,
commit: bool = True, commit: bool = True,
) -> "Track": ) -> "Track":
@ -639,7 +640,7 @@ class Track(ScrobblableMixin):
Optionally, we can update any found artists with overwrite.""" Optionally, we can update any found artists with overwrite."""
album = None album = None
if album_name: if album_name:
logger.info("Looking up album for: {album_name}") logger.info(f"Looking up album for: {album_name}")
album = Album.find_or_create(name=album_name, artist_name=artist_name) album = Album.find_or_create(name=album_name, artist_name=artist_name)
artist = album.album_artist artist = album.album_artist
else: else:
@ -652,41 +653,36 @@ class Track(ScrobblableMixin):
lookup_keys["base_run_time_seconds"] = run_time_seconds lookup_keys["base_run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}") logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first() track = cls.objects.filter(**lookup_keys).first()
if track:
logger.info(
"Found match for track by name and artist, not going to musicbrainz ",
extra={
"track_id": track.id,
"title": title,
"artist_name": artist_name,
"run_time_seconds": run_time_seconds,
},
)
return track
track = cls.objects.filter(title=title, artist=artist).first()
if not track: if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist) track = cls.objects.filter(title=title, artist=artist).first()
if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist)
if album: if album:
track.albums.add(album) track.albums.add(album)
if enrich or not track.base_run_time_seconds: if enrich or not track.base_run_time_seconds:
logger.info( if mbid and run_time_seconds:
f"Enriching track {track}", track.base_run_time_seconds = run_time_seconds
extra={ track.musicbrainz_id = mbid
"title": title, else:
"artist_name": artist_name, logger.info(
"track_id": track.id, f"Enriching track {track}",
}, extra={
) "title": title,
try: "artist_name": artist_name,
mbid, length = get_recording_mbid_exact(title, artist_name, album_name) "track_id": track.id,
except Exception: },
print("No musicbrainz result found, cannot enrich") )
return track try:
track.base_run_time_seconds = run_time_seconds or int(length / 1000) mbid, length = get_recording_mbid_exact(
track.musicbrainz_id = mbid title, artist_name, album_name
)
except Exception:
print("No musicbrainz result found, cannot enrich")
return track
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
track.musicbrainz_id = mbid
if commit: if commit:
track.save() track.save()

View File

@ -1,9 +1,14 @@
import calendar
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pylast import pylast
import pytz import pytz
from celery import shared_task
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from music.models import Track from music.models import Track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,6 +27,8 @@ PYLAST_ERRORS = tuple(
class LastFM: class LastFM:
def __init__(self, user): def __init__(self, user):
self.user = None
self.vrobbler_user = user
try: try:
self.client = pylast.LastFMNetwork( self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"), api_key=getattr(settings, "LASTFM_API_KEY"),
@ -30,24 +37,28 @@ class LastFM:
password_hash=pylast.md5(user.profile.lastfm_password), password_hash=pylast.md5(user.profile.lastfm_password),
) )
self.user = self.client.get_user(user.profile.lastfm_username) self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e: except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}") logger.error(f"Error during Last.fm setup: {e}")
raise
def import_from_lastfm(self, last_processed=None): def import_from_lastfm(self, last_processed=None, time_to=None):
"""Given a last processed time, import all scrobbles from LastFM since then""" """Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
new_scrobbles = [] new_scrobbles = []
source = "Last.fm" source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed) lastfm_scrobbles = self.get_last_scrobbles(
time_from=last_processed, time_to=time_to
)
for lfm_scrobble in lastfm_scrobbles: for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create( track = Track.find_or_create(
title=lfm_scrobble.get("title"), title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"), artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"), album_name=lfm_scrobble.get("album", ""),
enrich=True, run_time_seconds=lfm_scrobble.get("run_time_seconds"),
mbid=lfm_scrobble.get("mbid"),
enrich=False,
) )
tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz( tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
@ -92,6 +103,20 @@ class LastFM:
) )
return created return created
def get_earliest_scrobble_timestamp(self) -> datetime | None:
"""Return the user's Last.fm registration date as the earliest
possible scrobble timestamp.
Uses ``get_unixtime_registered()`` which is a single API call.
Returns ``None`` if the user has no scrobbles at all.
"""
if not self.get_last_scrobbles(check=True):
return None
registered_ts = self.user.get_unixtime_registered()
earliest = datetime.fromtimestamp(registered_ts, tz=pytz.utc)
logger.info("Last.fm user %s registered on %s", self.user.name, earliest)
return earliest
def get_last_scrobbles(self, time_from=None, time_to=None, check=False): def get_last_scrobbles(self, time_from=None, time_to=None, check=False):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled """Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks""" tracks"""
@ -102,14 +127,12 @@ class LastFM:
if time_to: if time_to:
lfm_params["time_to"] = int(time_to.timestamp()) lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to: lfm_params["limit"] = 1 if check else None
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params) found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
if check and found_scrobbles: if check:
return True return bool(found_scrobbles)
for scrobble in found_scrobbles: for scrobble in found_scrobbles:
logger.info(f"Processing {scrobble}") logger.info(f"Processing {scrobble}")
@ -163,3 +186,112 @@ class LastFM:
} }
) )
return scrobbles return scrobbles
@shared_task
def dispatch_historical_imports(user_id):
"""Find every un-imported month and dispatch a sub-task for each.
Walks backward month by month from the user's earliest completed
``LastFmImport`` (or the current month if none exists) to their
first Last.fm scrobble, creating a ``LastFmImport`` record and
dispatching ``process_lastfm_import`` for each month that has
scrobbles.
"""
from scrobbles.models import LastFmImport
from scrobbles.tasks import process_lastfm_import
user = get_user_model().objects.filter(id=user_id).first()
if not user:
logger.error("User %s not found", user_id)
return 0
try:
lastfm = LastFM(user)
except Exception:
logger.exception("Failed to set up LastFM client for user %s", user_id)
return 0
logger.info("Looking up earliest Last.fm scrobble for user %s", user_id)
earliest_scrobble = lastfm.get_earliest_scrobble_timestamp()
if earliest_scrobble is None:
logger.info("User %s has no Last.fm scrobbles", user_id)
return 0
logger.info("Earliest Last.fm scrobble for user %s: %s", user_id, earliest_scrobble)
in_progress = LastFmImport.objects.filter(
user_id=user_id, processed_finished__isnull=True
).exists()
if in_progress:
logger.info(
"User %s has an import still in progress, skipping dispatch",
user_id,
)
return 0
def _first_of_month(dt):
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _last_of_month(dt):
_, last_day = calendar.monthrange(dt.year, dt.month)
return dt.replace(
day=last_day, hour=23, minute=59, second=59, microsecond=999999
)
earliest = (
LastFmImport.objects.filter(user_id=user_id, processed_finished__isnull=False)
.order_by("processed_finished")
.first()
)
if earliest:
earliest_log_scrobble = earliest.scrobbles().order_by("timestamp").first()
cursor = (
_first_of_month(earliest_log_scrobble.timestamp)
if earliest_log_scrobble
else earliest.processed_finished
)
logger.info(
"Found existing import; earliest scrobble %s, cursor set to %s",
earliest_log_scrobble.timestamp if earliest_log_scrobble else None,
cursor,
)
else:
cursor = timezone.now()
logger.info("No existing imports; cursor set to %s", cursor)
dispatched = 0
while True:
cursor = _first_of_month(cursor) - relativedelta(days=1)
month_start = _first_of_month(cursor)
month_end = _last_of_month(cursor)
if month_end < earliest_scrobble:
logger.info("Reached earliest scrobble at %s, stopping", earliest_scrobble)
break
logger.info(
"Checking for Last.fm scrobbles in range %s to %s",
month_start.date(),
month_end.date(),
)
has = lastfm.get_last_scrobbles(
time_from=month_start, time_to=month_end, check=True
)
if not has:
continue
lfm_import = LastFmImport.objects.create(user_id=user_id)
process_lastfm_import.delay(
lfm_import.id, time_from=month_start, time_to=month_end
)
logger.info(
"%s %s dispatched (import %s)",
month_start.date(),
month_end.date(),
lfm_import.id,
)
dispatched += 1
logger.info("Dispatched %d monthly batch(es) for user %s", dispatched, user_id)
return dispatched

View File

@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
User = get_user_model() User = get_user_model()
@ -11,11 +11,17 @@ class Command(BaseCommand):
type=int, type=int,
help="User ID to import historical scrobbles for", help="User ID to import historical scrobbles for",
) )
parser.add_argument(
"--async",
action="store_true",
dest="async",
help="Dispatch the task to Celery instead of running inline",
)
def handle(self, *args, **options): def handle(self, *args, **options):
from scrobbles.models import LastFmImport from scrobbles.importers.lastfm import dispatch_historical_imports
user_id = options.get("user_id") user_id = options["user_id"]
user = User.objects.filter(id=user_id).first() user = User.objects.filter(id=user_id).first()
if not user: if not user:
@ -29,17 +35,15 @@ class Command(BaseCommand):
) )
return return
lfm_import = LastFmImport.objects.create( if options["async"]:
user=user, dispatch_historical_imports.delay(user_id)
earliest_timestamp=None, self.stdout.write(
last_batch_imported=None, self.style.SUCCESS(f"Dispatched historical import task for user {user}")
import_in_progress=False, )
) else:
count = dispatch_historical_imports(user_id)
lfm_import.start_historical_import() self.stdout.write(
self.style.SUCCESS(
self.stdout.write( f"Dispatched {count} monthly batch(es) for user {user}"
self.style.SUCCESS( )
f"Started historical LastFM import for {user} (import ID: {lfm_import.id})"
) )
)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-05-24 19:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0085_scale_csv_import_original_filename"),
]
operations = [
migrations.AddField(
model_name="lastfmimport",
name="import_in_progress",
field=models.BooleanField(default=False),
),
]

View File

@ -10,9 +10,9 @@ from zoneinfo import ZoneInfo
import pendulum import pendulum
import pytz import pytz
from beers.models import Beer from beers.models import Beer
from birds.models import BirdingLocation
from boardgames.models import BoardGame from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file from books.koreader import process_koreader_sqlite_file
from birds.models import BirdingLocation
from books.models import Book, BookLogData, BookPageLogData, Paper from books.models import Book, BookLogData, BookPageLogData, Paper
from bricksets.models import BrickSet from bricksets.models import BrickSet
from charts.utils import build_charts from charts.utils import build_charts
@ -343,6 +343,8 @@ class TrailGPXImport(BaseFileImportMixin):
class LastFmImport(BaseFileImportMixin): class LastFmImport(BaseFileImportMixin):
import_in_progress = models.BooleanField(default=False)
class Meta: class Meta:
verbose_name = "Last.FM Import" verbose_name = "Last.FM Import"
@ -353,7 +355,7 @@ class LastFmImport(BaseFileImportMixin):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid}) return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid})
def process(self, import_all=False): def process(self, import_all=False, time_from=None, time_to=None):
"""Import scrobbles found on LastFM""" """Import scrobbles found on LastFM"""
if self.user.id == 1: if self.user.id == 1:
@ -363,27 +365,32 @@ class LastFmImport(BaseFileImportMixin):
logger.info(f"{self} already processed on {self.processed_finished}") logger.info(f"{self} already processed on {self.processed_finished}")
return return
last_import = None
if not import_all:
try:
last_import = LastFmImport.objects.exclude(id=self.id).last()
except:
pass
if not import_all and not last_import:
logger.warn(
"No previous import, to import all Last.fm scrobbles, pass import_all=True"
)
return
lastfm = LastFM(self.user) lastfm = LastFM(self.user)
last_processed = None last_processed = None
if last_import:
last_processed = last_import.processed_finished if time_from is not None or time_to is not None:
last_processed = time_from
else:
last_import = None
if not import_all:
try:
last_import = LastFmImport.objects.exclude(id=self.id).last()
except:
pass
if not import_all and not last_import:
logger.warn(
"No previous import, to import all Last.fm scrobbles, "
"pass import_all=True"
)
return
if last_import:
last_processed = last_import.processed_finished
self.mark_started() self.mark_started()
scrobbles = lastfm.import_from_lastfm(last_processed) scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles) self.record_log(scrobbles)
self.mark_finished() self.mark_finished()
@ -419,8 +426,8 @@ class RetroarchImport(BaseFileImportMixin):
self.mark_started() self.mark_started()
if self.lrtl_file: if self.lrtl_file:
import tempfile
import os import os
import tempfile
import zipfile import zipfile
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()

View File

@ -1,5 +1,5 @@
import logging import logging
from datetime import timedelta from datetime import datetime, timedelta
from celery import shared_task from celery import shared_task
from charts.utils import ( from charts.utils import (
@ -118,13 +118,14 @@ def process_bgstats_import(import_id):
@shared_task @shared_task
def process_lastfm_import(import_id): def process_lastfm_import(import_id, time_from=None, time_to=None):
LastFmImport = apps.get_model("scrobbles", "LastFMImport") LastFmImport = apps.get_model("scrobbles", "LastFMImport")
lastfm_import = LastFmImport.objects.filter(id=import_id).first() lastfm_import = LastFmImport.objects.filter(id=import_id).first()
if not lastfm_import: if not lastfm_import:
logger.warn(f"LastFmImport not found with id {import_id}") logger.warn(f"LastFmImport not found with id {import_id}")
return
lastfm_import.process() lastfm_import.process(time_from=time_from, time_to=time_to)
@shared_task @shared_task
@ -277,7 +278,7 @@ def _retention_files_to_delete(remote_files, now):
""" """
import re import re
from collections import defaultdict from collections import defaultdict
from datetime import timedelta, date from datetime import date, timedelta
pattern = re.compile(r"vrobbler-backup-(\d{4})_(\d{2})_(\d{2})\.sql\.gz") pattern = re.compile(r"vrobbler-backup-(\d{4})_(\d{2})_(\d{2})\.sql\.gz")
parsed = [] parsed = []
@ -312,12 +313,14 @@ def _run_remote_cleanup(ssh_key, ssh_host, remote_path):
Returns a summary string (e.g. "pruned 3 old backup(s)") or None. Returns a summary string (e.g. "pruned 3 old backup(s)") or None.
""" """
import subprocess
import re import re
import subprocess
result = subprocess.run( result = subprocess.run(
["ssh", "-i", ssh_key, ssh_host, "ls", "-1", remote_path], ["ssh", "-i", ssh_key, ssh_host, "ls", "-1", remote_path],
capture_output=True, text=True, timeout=30, capture_output=True,
text=True,
timeout=30,
) )
if result.returncode != 0: if result.returncode != 0:
logger.warning( logger.warning(
@ -345,9 +348,7 @@ def _run_remote_cleanup(ssh_key, ssh_host, remote_path):
rm_cmd.extend(f"{remote_path}/{f}" for f in batch) rm_cmd.extend(f"{remote_path}/{f}" for f in batch)
subprocess.run(rm_cmd, check=True, timeout=30) subprocess.run(rm_cmd, check=True, timeout=30)
logger.info( logger.info("backup_database: pruned %d remote backup(s)", len(to_delete))
"backup_database: pruned %d remote backup(s)", len(to_delete)
)
return f"pruned {len(to_delete)} old remote backup(s)" return f"pruned {len(to_delete)} old remote backup(s)"
@ -355,8 +356,8 @@ def _run_remote_cleanup(ssh_key, ssh_host, remote_path):
def backup_database(): def backup_database():
"""pg_dump + gzip, scp to remote, retention cleanup, ntfy notification.""" """pg_dump + gzip, scp to remote, retention cleanup, ntfy notification."""
import os import os
import subprocess
import re import re
import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -380,9 +381,12 @@ def backup_database():
pg_dump_cmd = [ pg_dump_cmd = [
"pg_dump", "pg_dump",
"--no-blobs", "--no-blobs",
"-h", db.get("HOST", "localhost"), "-h",
"-U", db.get("USER", "postgres"), db.get("HOST", "localhost"),
"-d", db["NAME"], "-U",
db.get("USER", "postgres"),
"-d",
db["NAME"],
] ]
logger.info("backup_database: dumping %s to %s", db["NAME"], backup_path) logger.info("backup_database: dumping %s to %s", db["NAME"], backup_path)
@ -390,9 +394,7 @@ def backup_database():
try: try:
with open(backup_path, "wb") as f: with open(backup_path, "wb") as f:
dump_proc = subprocess.Popen(pg_dump_cmd, stdout=subprocess.PIPE, env=env) dump_proc = subprocess.Popen(pg_dump_cmd, stdout=subprocess.PIPE, env=env)
gzip_proc = subprocess.Popen( gzip_proc = subprocess.Popen(["gzip"], stdin=dump_proc.stdout, stdout=f)
["gzip"], stdin=dump_proc.stdout, stdout=f
)
dump_proc.stdout.close() dump_proc.stdout.close()
gzip_proc.communicate() gzip_proc.communicate()
@ -402,9 +404,7 @@ def backup_database():
return return
dump_size = backup_path.stat().st_size dump_size = backup_path.stat().st_size
logger.info( logger.info("backup_database: dump complete (%.1f MB)", dump_size / 1_000_000)
"backup_database: dump complete (%.1f MB)", dump_size / 1_000_000
)
size_mb = dump_size / 1_000_000 size_mb = dump_size / 1_000_000
locations = [str(backup_path)] locations = [str(backup_path)]
@ -436,9 +436,7 @@ def backup_database():
ssh_host = f"{m.group(1)}@{m.group(2)}" ssh_host = f"{m.group(1)}@{m.group(2)}"
remote_path = m.group(3) remote_path = m.group(3)
logger.info("backup_database: pruning old remote backups") logger.info("backup_database: pruning old remote backups")
cleanup_summary = _run_remote_cleanup( cleanup_summary = _run_remote_cleanup(ssh_key, ssh_host, remote_path)
ssh_key, ssh_host, remote_path
)
else: else:
logger.warning( logger.warning(
"backup_database: DB_BACKUP_SSH_KEY and DB_BACKUP_SSH_DEST not set — " "backup_database: DB_BACKUP_SSH_KEY and DB_BACKUP_SSH_DEST not set — "
@ -498,7 +496,9 @@ def import_from_retroarch_all_users():
@shared_task @shared_task
def import_from_webdav_all_users(): def import_from_webdav_all_users():
"""Import from WebDAV for all users (replaces */2 cron).""" """Import from WebDAV for all users (replaces */2 cron)."""
from vrobbler.apps.scrobbles.importers.webdav import import_from_webdav_for_all_users from vrobbler.apps.scrobbles.importers.webdav import (
import_from_webdav_for_all_users,
)
import_from_webdav_for_all_users() import_from_webdav_for_all_users()
@ -519,7 +519,9 @@ def import_from_imap_all_users():
@shared_task @shared_task
def import_from_lichess_all_users(): def import_from_lichess_all_users():
"""Import chess games from Lichess for all users (replaces */20 cron).""" """Import chess games from Lichess for all users (replaces */20 cron)."""
from vrobbler.apps.boardgames.sources.lichess import import_chess_games_for_all_users from vrobbler.apps.boardgames.sources.lichess import (
import_chess_games_for_all_users,
)
import_chess_games_for_all_users() import_chess_games_for_all_users()
@ -527,7 +529,9 @@ def import_from_lichess_all_users():
@shared_task @shared_task
def send_notification_for_in_progress(): def send_notification_for_in_progress():
"""Send ntfy stop-notifications for in-progress scrobbles (replaces */3 cron).""" """Send ntfy stop-notifications for in-progress scrobbles (replaces */3 cron)."""
from vrobbler.apps.scrobbles.utils import send_stop_notifications_for_in_progress_scrobbles from vrobbler.apps.scrobbles.utils import (
send_stop_notifications_for_in_progress_scrobbles,
)
send_stop_notifications_for_in_progress_scrobbles() send_stop_notifications_for_in_progress_scrobbles()

View File

@ -2,8 +2,8 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from celery.schedules import crontab
import dj_database_url import dj_database_url
from celery.schedules import crontab
from dotenv import load_dotenv from dotenv import load_dotenv
TRUTHY = ("true", "1", "t") TRUTHY = ("true", "1", "t")