[music] Reorganize importer and fix lookups

This commit is contained in:
2025-07-25 10:20:49 -04:00
parent e8e989bb63
commit edf9fbd9c1
10 changed files with 742 additions and 347 deletions

View File

@ -50,17 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"
list_display = ( list_display = (
"title", "title",
"album", "primary_album",
"artist", "artist",
"musicbrainz_id", "musicbrainz_id",
) )
raw_id_fields = ( raw_id_fields = ("artist", "albums", "album")
"album",
"artist",
)
list_filter = ("album", "artist") list_filter = ("album", "artist")
search_fields = ("title",) search_fields = ("title",)
ordering = ("-created",) ordering = ("-created",)
filter_horizontal = [
"albums",
]
inlines = [ inlines = [
ScrobbleInline, ScrobbleInline,
] ]

View File

@ -1,162 +0,0 @@
import logging
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from music.models import Track
logger = logging.getLogger(__name__)
PYLAST_ERRORS = tuple(
getattr(pylast, exc_name)
for exc_name in (
"ScrobblingError",
"NetworkError",
"MalformedResponseError",
"WSError",
)
if hasattr(pylast, exc_name)
)
class LastFM:
def __init__(self, user):
try:
self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"),
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
username=user.profile.lastfm_username,
password_hash=pylast.md5(user.profile.lastfm_password),
)
self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}")
def import_from_lastfm(self, last_processed=None):
"""Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble
new_scrobbles = []
source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
timestamp = lfm_scrobble.get("timestamp")
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
track=track,
timezone=timezone,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
seconds_later = timestamp + timedelta(seconds=20)
existing = Scrobble.objects.filter(
created__gte=seconds_eariler,
created__lte=seconds_later,
track=track,
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
def get_last_scrobbles(self, time_from=None, time_to=None):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
lfm_params = {}
scrobbles = []
if time_from:
lfm_params["time_from"] = int(time_from.timestamp())
if time_to:
lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to:
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
for scrobble in found_scrobbles:
logger.debug(f"Processing {scrobble}")
run_time = None
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warning(e)
except pylast.WSError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,
"album": scrobble.album,
"title": scrobble.track.title,
"mbid": mbid,
"run_time_seconds": run_time,
"timestamp": timestamp,
}
)
return scrobbles

View File

@ -15,6 +15,11 @@ from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import ( from music.musicbrainz import (
get_album_metadata,
get_album_metadata_with_artist,
get_artist_metadata_extended,
get_recording_mbid_exact,
get_track_metadata_with_artist,
lookup_album_dict_from_mb, lookup_album_dict_from_mb,
lookup_album_from_mb, lookup_album_from_mb,
lookup_track_from_mb, lookup_track_from_mb,
@ -177,56 +182,75 @@ class Artist(TimeStampedModel):
@classmethod @classmethod
def find_or_create( def find_or_create(
cls, name: str = "", musicbrainz_id: str = "" cls, name: str, album_name: str = "", track_name: str = ""
) -> "Artist": ) -> "Artist":
"""The biggest challenge to finding artists is that the search often
fails miserably unless you can look it up along with an album or a track name.
Thus, when we find or create an artist, we should always provide an optional
album name or track name, but probably not both."""
if album_name:
logger.info(
f"Looking for artist with name {name} and album {album_name}"
)
if track_name:
logger.info(
f"Looking for artist with name {name} and track {track_name}"
)
keys = {} keys = {}
if name:
name = clean_artist_name(name)
keys["name"] = name
if musicbrainz_id: name = clean_artist_name(name)
keys["musicbrainz_id"] = musicbrainz_id keys["name"] = name
artist = cls.objects.filter(name=name).first()
if not keys: if artist:
raise Exception("Must have name, mb_id or both to lookup artist") return artist
artist = cls.objects.filter(**keys).first() # alt_name = None
artist_dict = {}
if album_name:
album_dict = get_album_metadata_with_artist(album_name, name)
if album_dict:
artist_dict = album_dict.get("primary_artist")
if track_name:
track_dict = get_track_metadata_with_artist(track_name, name)
if track_dict:
artist_dict = track_dict.get("primary_artist")
if not artist: if not artist_dict:
artist = cls.objects.filter( artist, created = cls.objects.get_or_create(name=name)
models.Q(name=name) | models.Q(alt_names__icontains=name) if created:
).first() artist.fix_metadata()
return artist
# Does not exist, look it up from Musicbrainz musicbrainz_id = artist_dict.get("mbid")
if not artist: found_name = artist_dict.get("name", name)
alt_name = None if found_name and name != found_name:
try: alt_name = found_name
artist_dict = lookup_artist_from_mb(name)
musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
if name != artist_dict.get("name", ""):
alt_name = name
name = artist_dict.get("name", "")
except ValueError:
pass
if musicbrainz_id:
artist = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if artist and alt_name:
if not artist.alt_names:
artist.alt_names = alt_name
else:
artist.alt_names += f"\\{alt_name}"
artist.save(update_fields=["alt_names"])
artist = cls.objects.filter(
name=name, musicbrainz_id=musicbrainz_id
).first()
if not artist: if not artist:
artist = cls.objects.create( artist = cls.objects.create(
name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name name=found_name,
musicbrainz_id=musicbrainz_id,
) )
# TODO maybe this should be spun off into an async task?
artist.fix_metadata() artist.fix_metadata()
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
# If we did find our artist, but the found name is slightly differnt, record that
# if artist and alt_name:
# if not artist.alt_names:
# artist.alt_names = alt_name
# else:
# artist.alt_names += f"\\{alt_name}"
# logger.info(
# f"Add alt_name {alt_name} to artist {artist}",
# extra={"alt_name": alt_name, "artist_id": artist.id},
# )
# artist.save(update_fields=["alt_names"])
return artist return artist
@ -319,7 +343,7 @@ class Album(TimeStampedModel):
) )
return return
if not self.allmusic_id or force: if self.album_artist and (not self.allmusic_id or force):
slug = get_allmusic_slug(self.album_artist.name, self.name) slug = get_allmusic_slug(self.album_artist.name, self.name)
if not slug: if not slug:
logger.info( logger.info(
@ -350,7 +374,12 @@ class Album(TimeStampedModel):
logger.info(f"No data for {self} found in TheAudioDB") logger.info(f"No data for {self} found in TheAudioDB")
return return
Album.objects.filter(pk=self.pk).update(**album_data) try:
Album.objects.filter(pk=self.pk).update(**album_data)
except:
logger.info(
f"Could not save info for album {self} with data {album_data}"
)
def scrape_bandcamp(self, force=False) -> None: def scrape_bandcamp(self, force=False) -> None:
if not self.bandcamp_id or force: if not self.bandcamp_id or force:
@ -489,65 +518,75 @@ class Album(TimeStampedModel):
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a" return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
@classmethod @classmethod
def find_or_create( def find_or_create(cls, name: str, artist_name: str) -> "Album":
cls, name: str, artist_name: str, musicbrainz_id: str = "" logger.info(
) -> "Album": f"Looking for album with name {name} and artist_name {artist_name}"
if not name or not artist_name: )
raise Exception( artist = Artist.find_or_create(artist_name, album_name=name)
"Must have at least name and artist name to lookup album" album_dict = get_album_metadata_with_artist(name, artist.name)
if not album_dict:
logger.info(
f"Could not find album {name} with artist {artist.name} on musicbrainz"
) )
album, created = Album.objects.get_or_create(
album = None
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
name=name, name=name,
album_artist__name=artist_name, )
).first() if created:
if not album and musicbrainz_id: # album.fix_metadata()
album = cls.objects.filter( # album.fetch_artwork()
musicbrainz_id=musicbrainz_id, ...
).first() return album
if not album:
album = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name),
album_artist__name=artist_name,
).first()
if not album: if not artist:
alt_name = None artist_dict = album_dict.get("primary_artist", {})
try: if artist_dict:
album_dict = lookup_album_dict_from_mb( artist = Artist.objects.filter(
name, artist_name=artist_name musicbrainz_id=artist_dict.get("mbid"),
)
musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
found_name = album_dict.get("title", "")
if found_name and name != found_name:
alt_name = name
name = found_name
except ValueError:
pass
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first() ).first()
if album and alt_name: if not artist:
if not album.alt_names: artist = Artist.objects.create(
album.alt_names = alt_name musicbrainz_id=artist_dict.get("mbid"),
else: )
album.alt_names += f"\\{alt_name}"
album.save(update_fields=["alt_names"]) extra_artists = []
if not album: if not artist and len(album_dict.get("all_artists")) > 1:
artist = Artist.find_or_create(name=artist_name) artist = Artist.objects.filter(name="Various Artists").first()
album = cls.objects.create( extra_artists.append(artist)
name=name,
album_artist=artist, if not artist:
musicbrainz_id=musicbrainz_id, raise Exception("No album artist found, and not a compliation")
alt_names=alt_name,
) album = cls.objects.filter(
# TODO maybe do this in a separate process? models.Q(name=name) | models.Q(alt_names__icontains=name),
album.fix_metadata() album_artist=artist,
).first()
alt_name = None
found_name = album_dict.get("album_title", name)
if found_name and name != found_name:
alt_name = name
album = Album.objects.filter(
name=found_name, musicbrainz_id=album_dict.get("mbid")
).first()
if not album:
year = None
if album_dict.get("release_date"):
year = album_dict.get("release_date", "").split("-")[0]
album = Album.objects.create(
name=found_name,
musicbrainz_id=album_dict.get("mbid"),
musicbrainz_releasegroup_id=album_dict.get(
"release_group_mbid"
),
year=year,
album_artist=artist,
alt_names=alt_name,
)
album.artists.add(*extra_artists)
album.fetch_artwork()
return album return album
@ -568,6 +607,8 @@ class Track(ScrobblableMixin):
@property @property
def primary_album(self): def primary_album(self):
if self.album:
return self.album
return self.albums.order_by("year").first() return self.albums.order_by("year").first()
def get_absolute_url(self): def get_absolute_url(self):
@ -594,8 +635,8 @@ class Track(ScrobblableMixin):
url = "" url = ""
if self.artist.thumbnail: if self.artist.thumbnail:
url = self.artist.thumbnail_medium.url url = self.artist.thumbnail_medium.url
if self.album and self.album.cover_image: if self.primary_album and self.primary_album.cover_image:
url = self.album.cover_image_medium.url url = self.primary_album.cover_image_medium.url
return url return url
@classmethod @classmethod
@ -603,9 +644,8 @@ class Track(ScrobblableMixin):
cls, cls,
title: str = "", title: str = "",
artist_name: str = "", artist_name: str = "",
musicbrainz_id: str = "",
album_name: str = "", album_name: str = "",
run_time_seconds: int = 900, run_time_seconds: int | None = None,
enrich: bool = False, enrich: bool = False,
commit: bool = True, commit: bool = True,
) -> "Track": ) -> "Track":
@ -615,62 +655,65 @@ class Track(ScrobblableMixin):
name name
Optionally, we can update any found artists with overwrite.""" Optionally, we can update any found artists with overwrite."""
created = False album = None
if musicbrainz_id: if album_name:
track = cls.objects.filter(musicbrainz_id=musicbrainz_id).first() logger.info("Looking up album for: {album_name}")
artist = track.artist album = Album.find_or_create(
if not track and not (title and album_name): name=album_name, artist_name=artist_name
raise Exception(
"Cannot find track with musicbrainz_id and no track title or artist name provided."
)
else:
artist = Artist.find_or_create(artist_name)
track, created = cls.objects.get_or_create(
title=title, artist=artist
) )
artist = album.album_artist
else:
artist = Artist.find_or_create(artist_name, track_name=title)
if not artist:
artist = Artist.find_or_create(artist_name)
if not created: lookup_keys = {"title": title, "artist": artist}
if run_time_seconds:
lookup_keys["run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first()
if track:
logger.info( logger.info(
"Found exact match for track by name and artist", "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:
track, _ = cls.objects.get_or_create(title=title, artist=artist)
if album:
track.albums.add(album)
if enrich or not track.run_time_seconds:
logger.info(
f"Enriching track {track}",
extra={ extra={
"title": title, "title": title,
"artist_name": artist_name, "artist_name": artist_name,
"track_id": track.id, "track_id": track.id,
}, },
) )
try:
if track.album and album_name != track.album.name: mbid, length = get_recording_mbid_exact(
# TODO found track, but it's on a different album ... associations? title, artist_name, album_name
logger.info("Found track by artist, but album is different.")
album = Album.find_or_create()
if enrich:
album = None
if album_name:
album = Album.find_or_create(album_name)
if artist.musicbrainz_id:
track_dict = lookup_track_from_mb(title, artist.musicbrainz_id)
musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
found_title: bool = track_dict.get("name", False)
mismatched_title: bool = title != track_dict.get("name", "")
if found_title and mismatched_title:
logger.warning(
"Source track title and found title do not match",
extra={"title": title, "track_dict": track_dict},
)
if not run_time_seconds:
run_time_seconds = int(
int(track_dict.get("length", 900000)) / 1000
) )
except Exception:
track.album = album print("No musicbrainz result found, cannot enrich")
track.artist = artist return track
track.run_time_seconds = run_time_seconds track.run_time_seconds = run_time_seconds or int(length / 1000)
track.musicbrainz_id = mbid
if commit: if commit:
track.save() track.save()
# TODO Also set cover art and tags
return track return track
def fix_metadata(self, force_update=False):
...

View File

@ -1,16 +1,17 @@
from datetime import datetime
import logging import logging
from typing import Iterable
import musicbrainzngs import musicbrainzngs
from dateutil.parser import parse from dateutil.parser import parse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
def lookup_album_from_mb(musicbrainz_id: str) -> dict: def lookup_album_from_mb(musicbrainz_id: str) -> dict:
release_dict = {} release_dict = {}
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
release_data = musicbrainzngs.get_release_by_id( release_data = musicbrainzngs.get_release_by_id(
musicbrainz_id, musicbrainz_id,
includes=["artists", "release-groups", "recordings"], includes=["artists", "release-groups", "recordings"],
@ -51,7 +52,6 @@ def lookup_album_from_mb(musicbrainz_id: str) -> dict:
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict: def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = {} top_result = {}
@ -84,7 +84,6 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
def lookup_artist_from_mb(artist_name: str) -> dict: def lookup_artist_from_mb(artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
try: try:
top_result = musicbrainzngs.search_artists(artist=artist_name)[ top_result = musicbrainzngs.search_artists(artist=artist_name)[
@ -104,7 +103,7 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
def lookup_track_from_mb( def lookup_track_from_mb(
track_name: str, artist_mb_id: str, album_mb_id: str track_name: str, artist_mb_id: str, album_mb_id: str = ""
) -> dict: ) -> dict:
logger.info( logger.info(
"[lookup_track_from_mb] called", "[lookup_track_from_mb] called",
@ -114,7 +113,6 @@ def lookup_track_from_mb(
"album_mb_id": album_mb_id, "album_mb_id": album_mb_id,
}, },
) )
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
try: try:
results = musicbrainzngs.search_recordings( results = musicbrainzngs.search_recordings(
@ -138,3 +136,352 @@ def lookup_track_from_mb(
return {} return {}
return top_result return top_result
def get_album_metadata(album_name, artist_name, strict=True) -> dict:
"""
Get detailed metadata for an album from MusicBrainz.
:param album_name: Name of the album
:param artist_name: Name of the artist
:param strict: If True, only exact matches on album and artist (case-insensitive)
:return: dict with album metadata, or None if not found
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=5
)
for release in result.get("release-list", []):
title = release["title"]
primary_artist = release["artist-credit"][0]["artist"]["name"]
title_match = title.lower() == album_name.lower()
artist_match = primary_artist.lower() == artist_name.lower()
if not strict or (title_match and artist_match):
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if isinstance(ac, dict) and "artist" in ac
]
return {
"album_title": title,
"primary_artist": primary_artist,
"all_artists": all_artists,
"mbid": release["id"],
"release_date": release.get(
"date"
), # May be partial (e.g., just year)
"release_group_mbid": release["release-group"]["id"],
}
return {}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return {}
def get_recording_mbid_exact(
track_title: str, artist_name: str, album_name: str
) -> tuple[str, int]:
try:
result = musicbrainzngs.search_releases(
artist=artist_name, release=album_name, limit=1
)
releases = result.get("release-list", [])
if not releases:
raise Exception("No releases found")
release_id = releases[0]["id"]
release_data = musicbrainzngs.get_release_by_id(
release_id, includes=["recordings"]
)
tracks = release_data["release"]["medium-list"][0]["track-list"]
for track in tracks:
if track["recording"]["title"].lower() == track_title.lower():
return track["recording"]["id"], int(
track["recording"]["length"]
)
raise Exception("No recording found")
except musicbrainzngs.WebServiceError as e:
print(f"MusicBrainz error: {e}")
raise Exception(e)
def get_artist_metadata_extended(artist_name, strict=True):
"""
Fetch artist metadata including MBID, name, origin, tags, and description.
:param artist_name: The artist's name
:param strict: If True, only return exact name match
:return: dict with metadata, or None if not found
"""
try:
# Step 1: Search for artist
search_results = musicbrainzngs.search_artists(
artist=artist_name, limit=5
)
for artist in search_results.get("artist-list", []):
if not strict or artist["name"].lower() == artist_name.lower():
mbid = artist["id"]
# Step 2: Get detailed info about the artist
details = musicbrainzngs.get_artist_by_id(
mbid, includes=["tags", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
# Step 3: Try to find a Wikipedia or Wikidata link
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata":
description_url = rel["target"]
return {
"mbid": mbid,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url, # user can fetch summary if needed
}
return None
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return None
def get_artist_metadata_brief(artist_id):
"""Fetch basic artist metadata by MBID."""
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def parse_date(date_str):
"""Parse MusicBrainz date format into sortable datetime object."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def get_album_metadata_with_artist(album_name, artist_name, strict=True):
"""
Get metadata for the earliest release of an album and its primary artist.
:param album_name: Album title
:param artist_name: Name of the artist
:param strict: If True, enforce exact match for album and artist
:return: dict with album and primary artist metadata
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=100
)
query_album = album_name.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_releases = []
for release in result.get("release-list", []):
release_title = release["title"].strip()
primary_artist = release["artist-credit"][0]["artist"]
artist_name_actual = primary_artist["name"].strip()
if strict:
if release_title.casefold() != query_album:
continue
if artist_name_actual.casefold() != query_artist:
continue
release_date = parse_date(release.get("date"))
valid_releases.append((release, release_date))
if not valid_releases:
return None
# Sort releases by earliest release date
valid_releases.sort(key=lambda x: x[1] or datetime.max)
release, _ = valid_releases[0]
primary_artist = release["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"album_title": release["title"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"mbid": release["id"],
"release_group_mbid": release["release-group"]["id"],
"release_date": release.get("date"),
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (album lookup):", e)
return None
def get_artist_metadata_brief(artist_id):
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def get_track_metadata_with_artist(track_title, artist_name, strict=True):
"""
Get metadata for the earliest-known recording of a track, including artist info.
:param track_title: Track title
:param artist_name: Artist name
:param strict: If True, match exactly (case-insensitive)
:return: dict with track + release + artist metadata
"""
try:
result = musicbrainzngs.search_recordings(
recording=track_title, artist=artist_name, limit=100
)
query_track = track_title.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_candidates = []
for recording in result.get("recording-list", []):
rec_title = recording["title"].strip()
artist_credit = recording["artist-credit"][0]["artist"]
artist_name_actual = artist_credit["name"].strip()
if strict:
if rec_title.casefold() != query_track:
continue
if artist_name_actual.casefold() != query_artist:
continue
if "release-list" not in recording:
continue
for release in recording["release-list"]:
release_date = parse_date(release.get("date"))
if release_date:
valid_candidates.append(
(recording["id"], release, release_date)
)
if not valid_candidates:
return None
# Pick the earliest release
valid_candidates.sort(key=lambda x: x[2])
recording_id, release, _ = valid_candidates[0]
# Fetch full recording info
full_recording = musicbrainzngs.get_recording_by_id(
recording_id, includes=["artists", "releases"]
)["recording"]
primary_artist = full_recording["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in full_recording["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"track_title": full_recording["title"],
"length_ms": full_recording.get("length"),
"recording_mbid": recording_id,
"release_title": release["title"],
"release_date": release.get("date"),
"release_group_mbid": release["release-group"]["id"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (track lookup):", e)
return None

View File

@ -9,12 +9,14 @@ logger = logging.getLogger(__name__)
def clean_artist_name(name: str) -> str: def clean_artist_name(name: str) -> str:
"""Remove featured names from artist string.""" """Remove featured names from artist string."""
if "feat." in name.lower(): if " feat. " in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip() name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if "featuring" in name.lower(): if " w. " in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip() name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
if "&" in name.lower(): # if " & " in name.lower() and "of the wand" not in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip() # name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
return name return name

View File

@ -0,0 +1,163 @@
import logging
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from music.models import Track
logger = logging.getLogger(__name__)
PYLAST_ERRORS = tuple(
getattr(pylast, exc_name)
for exc_name in (
"ScrobblingError",
"NetworkError",
"MalformedResponseError",
"WSError",
)
if hasattr(pylast, exc_name)
)
class LastFM:
def __init__(self, user):
try:
self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"),
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
username=user.profile.lastfm_username,
password_hash=pylast.md5(user.profile.lastfm_password),
)
self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}")
def import_from_lastfm(self, last_processed=None):
"""Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble
new_scrobbles = []
source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
enrich=True,
)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
timestamp = lfm_scrobble.get("timestamp")
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
track=track,
timezone=timezone,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
seconds_later = timestamp + timedelta(seconds=20)
existing = Scrobble.objects.filter(
created__gte=seconds_eariler,
created__lte=seconds_later,
track=track,
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
def get_last_scrobbles(self, time_from=None, time_to=None):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
lfm_params = {}
scrobbles = []
if time_from:
lfm_params["time_from"] = int(time_from.timestamp())
if time_to:
lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to:
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
for scrobble in found_scrobbles:
logger.info(f"Processing {scrobble}")
run_time = None
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warning(e)
except pylast.WSError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,
"album": scrobble.album,
"title": scrobble.track.title,
"mbid": mbid,
"run_time_seconds": run_time,
"timestamp": timestamp,
}
)
return scrobbles

View File

@ -1,23 +1,21 @@
import codecs import codecs
import csv import csv
import logging import logging
from datetime import datetime, timedelta
import pytz
import requests import requests
from django.contrib.auth import get_user_model
from music.models import Track from music.models import Track
from scrobbles.constants import AsTsvColumn from scrobbles.constants import AsTsvColumn
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None): def import_audioscrobbler_tsv_file(file_path, user_id):
"""Takes a path to a file of TSV data and imports it as past scrobbles""" """Takes a path to a file of TSV data and imports it as past scrobbles"""
new_scrobbles = [] new_scrobbles = []
if not user_tz: user = get_user_model().objects.get(id=user_id)
user_tz = pytz.utc
is_os_file = "https://" not in file_path is_os_file = "https://" not in file_path
@ -44,11 +42,13 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
) )
continue continue
album_name = row[AsTsvColumn["ALBUM_NAME"].value]
track = Track.find_or_create( track = Track.find_or_create(
title=row[AsTsvColumn["TRACK_NAME"].value], title=row[AsTsvColumn["TRACK_NAME"].value],
musicbrainz_id=row[AsTsvColumn["MB_ID"].value],
artist_name=row[AsTsvColumn["ARTIST_NAME"].value], artist_name=row[AsTsvColumn["ARTIST_NAME"].value],
album_name=row[AsTsvColumn["ALBUM_NAME"].value], album_name=album_name,
run_time_seconds=int(row[AsTsvColumn["RUN_TIME_SECONDS"].value]),
enrich=True,
) )
# TODO Set all this up as constants # TODO Set all this up as constants
@ -62,22 +62,26 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
) )
continue continue
timestamp = timestamp_user_tz_to_utc( timestamp = user.profile.get_timestamp_with_tz(
int(row[AsTsvColumn["TIMESTAMP"].value]), user_tz datetime.fromtimestamp(int(row[AsTsvColumn["TIMESTAMP"].value]))
) )
stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds)
new_scrobble = Scrobble( new_scrobble = Scrobble(
user_id=user_id, user=user,
timestamp=timestamp, timestamp=timestamp,
stop_timestamp=stop_timestamp,
source=source, source=source,
log={"rockbox_info": rockbox_info}, log={"rockbox_info": rockbox_info, "album_name": album_name},
playback_position_seconds=track.run_time_seconds,
track=track, track=track,
played_to_completion=True, played_to_completion=True,
in_progress=False, in_progress=False,
media_type=Scrobble.MediaType.TRACK, media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
) )
existing = Scrobble.objects.filter( existing = Scrobble.objects.filter(
timestamp=timestamp, track=track timestamp=timestamp, track=track, user=user
).first() ).first()
if existing: if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}") logger.debug(f"Skipping existing scrobble {new_scrobble}")

View File

@ -6,6 +6,7 @@ from collections import defaultdict
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pendulum import pendulum
import pytz import pytz
from beers.models import Beer from beers.models import Beer
@ -26,7 +27,6 @@ from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent from lifeevents.models import LifeEvent
from locations.models import GeoLocation from locations.models import GeoLocation
from moods.models import Mood from moods.models import Mood
from music.lastfm import LastFM
from music.models import Artist, Track from music.models import Artist, Track
from podcasts.models import PodcastEpisode from podcasts.models import PodcastEpisode
from profiles.utils import ( from profiles.utils import (
@ -40,6 +40,7 @@ from profiles.utils import (
from puzzles.models import Puzzle from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import NtfyNotification
from scrobbles.stats import build_charts from scrobbles.stats import build_charts
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
@ -247,7 +248,7 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
tsv_file = models.FileField(upload_to=get_path, **BNULL) tsv_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False): def process(self, force=False):
from scrobbles.tsv import process_audioscrobbler_tsv_file from scrobbles.importers.tsv import import_audioscrobbler_tsv_file
if self.processed_finished and not force: if self.processed_finished and not force:
logger.info( logger.info(
@ -257,13 +258,8 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
self.mark_started() self.mark_started()
tz = None scrobbles = import_audioscrobbler_tsv_file(
user_id = None self.upload_file_path, self.user.id
if self.user:
user_id = self.user.id
tz = self.user.profile.tzinfo
scrobbles = process_audioscrobbler_tsv_file(
self.upload_file_path, user_id, user_tz=tz
) )
self.record_log(scrobbles) self.record_log(scrobbles)
self.mark_finished() self.mark_finished()
@ -758,8 +754,10 @@ class Scrobble(TimeStampedModel):
@property @property
def local_stop_timestamp(self): def local_stop_timestamp(self):
if self.stop_tiemstamp: if self.stop_timestamp:
return timezone.localtime(self.stop_timestamp, timezone=self.tzinfo) return timezone.localtime(
self.stop_timestamp, timezone=self.tzinfo
)
@property @property
def scrobble_media_key(self) -> str: def scrobble_media_key(self) -> str:

View File

@ -132,7 +132,6 @@ def jellyfin_scrobble_media(
run_time_seconds=convert_to_seconds( run_time_seconds=convert_to_seconds(
post_data.get("RunTime", 900000) post_data.get("RunTime", 900000)
), ),
musicbrainz_id=post_data.get("Provider_musicbrainztrack", ""),
) )
# A hack because we don't worry about updating music ... we either finish it or we don't # A hack because we don't worry about updating music ... we either finish it or we don't
playback_position_seconds = 0 playback_position_seconds = 0

View File

@ -1,8 +1,9 @@
import hashlib import hashlib
import logging import logging
import re import re
from datetime import datetime, timedelta, tzinfo from datetime import datetime, timedelta
from urllib.parse import urlparse from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import pytz import pytz
from django.apps import apps from django.apps import apps
@ -24,7 +25,7 @@ logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
def timestamp_user_tz_to_utc(timestamp: int, user_tz: tzinfo) -> datetime: def timestamp_user_tz_to_utc(timestamp: int, user_tz: ZoneInfo) -> datetime:
return user_tz.localize(datetime.utcfromtimestamp(timestamp)).astimezone( return user_tz.localize(datetime.utcfromtimestamp(timestamp)).astimezone(
pytz.utc pytz.utc
) )