[music] Tracks can have multiple artists
This commit is contained in:
@ -93,7 +93,7 @@ fetching and simple saving.
|
|||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
:END:
|
||||||
* Backlog [30/47] :vrobbler:project:personal:
|
* Backlog [32/47] :vrobbler:project:personal:
|
||||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||||
@ -475,7 +475,7 @@ to GMT to save it in the database.
|
|||||||
|
|
||||||
It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
|
It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
|
||||||
|
|
||||||
** TODO [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
||||||
:END:
|
:END:
|
||||||
@ -496,10 +496,11 @@ or at least make it optional, and then require a M2M between Track and Artist.
|
|||||||
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
|
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
|
||||||
Billy`
|
Billy`
|
||||||
|
|
||||||
** TODO [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
|
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
|
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
||||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||||
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
|
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
|
||||||
|
|||||||
@ -143,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
|
|||||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Same in the End",
|
title="Same in the End",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
|
track.artists.add(artist)
|
||||||
track.albums.add(album)
|
track.albums.add(album)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
@ -181,9 +182,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
|||||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Emotion",
|
title="Emotion",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
|
track.artists.add(artist)
|
||||||
track.albums.add(album)
|
track.albums.add(album)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
@ -225,9 +227,10 @@ def test_mopidy_track_webhook_stores_raw_data(
|
|||||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Same in the End",
|
title="Same in the End",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
|
track.artists.add(artist)
|
||||||
track.albums.add(album)
|
track.albums.add(album)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
@ -265,10 +268,11 @@ def test_mopidy_track_webhook_stores_album_id(
|
|||||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Same in the End",
|
title="Same in the End",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
album=album,
|
album=album,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
|
track.artists.add(artist)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
mock_track_fc.return_value = track
|
mock_track_fc.return_value = track
|
||||||
@ -303,9 +307,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
|
|||||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Emotion",
|
title="Emotion",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
|
track.artists.add(artist)
|
||||||
track.albums.add(album)
|
track.albums.add(album)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
@ -347,11 +352,11 @@ def test_jellyfin_track_webhook_stores_album_id(
|
|||||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title="Emotion",
|
title="Emotion",
|
||||||
artist=artist,
|
artist_fk=artist,
|
||||||
album=album,
|
album=album,
|
||||||
base_run_time_seconds=60,
|
base_run_time_seconds=60,
|
||||||
)
|
)
|
||||||
track.albums.add(album)
|
track.artists.add(artist)
|
||||||
|
|
||||||
mock_artist_fc.return_value = artist
|
mock_artist_fc.return_value = artist
|
||||||
mock_track_fc.return_value = track
|
mock_track_fc.return_value = track
|
||||||
|
|||||||
@ -104,8 +104,8 @@ def build_charts(
|
|||||||
|
|
||||||
media_config = {
|
media_config = {
|
||||||
"artist": {
|
"artist": {
|
||||||
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
|
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
|
||||||
"values": "track__artist",
|
"values": "track__artists",
|
||||||
"annotate": Count("id", distinct=True),
|
"annotate": Count("id", distinct=True),
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from music.models import Album, Artist, Track
|
||||||
from music.models import Artist, Album, Track
|
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
|
|||||||
"artist",
|
"artist",
|
||||||
"musicbrainz_id",
|
"musicbrainz_id",
|
||||||
)
|
)
|
||||||
raw_id_fields = ("artist", "albums", "album")
|
raw_id_fields = ("artist_fk", "artists", "albums", "album")
|
||||||
list_filter = ("album", "artist")
|
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
filter_horizontal = [
|
filter_horizontal = [
|
||||||
"albums",
|
"albums",
|
||||||
|
"artists",
|
||||||
]
|
]
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
|
|||||||
@ -149,7 +149,7 @@ def live_charts(
|
|||||||
|
|
||||||
|
|
||||||
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
||||||
return Scrobble.objects.filter(track__artist=artist_id).count()
|
return Scrobble.objects.filter(track__artists=artist_id).count()
|
||||||
|
|
||||||
|
|
||||||
def live_tv_charts(
|
def live_tv_charts(
|
||||||
@ -158,7 +158,7 @@ def live_tv_charts(
|
|||||||
limit: int = 15,
|
limit: int = 15,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
from django.db.models import OuterRef, Subquery
|
from django.db.models import OuterRef, Subquery
|
||||||
from videos.models import Video, Series
|
from videos.models import Series, Video
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
tzinfo = now.tzinfo
|
tzinfo = now.tzinfo
|
||||||
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
|
|||||||
limit: int = 15,
|
limit: int = 15,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
from django.db.models import OuterRef, Subquery
|
from django.db.models import OuterRef, Subquery
|
||||||
from videos.models import Video, Channel
|
from videos.models import Channel, Video
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
tzinfo = now.tzinfo
|
tzinfo = now.tzinfo
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from taggit.serializers import TaggitSerializer, TagListSerializerField
|
||||||
|
|
||||||
from music.models import Album, Artist, Track
|
from music.models import Album, Artist, Track
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
|
|||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class TrackSerializer(serializers.HyperlinkedModelSerializer):
|
class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
|
||||||
|
tags = TagListSerializerField()
|
||||||
|
genre = TagListSerializerField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Track
|
model = Track
|
||||||
fields = "__all__"
|
fields = [
|
||||||
|
"url",
|
||||||
|
"id",
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
"artist_fk",
|
||||||
|
"artists",
|
||||||
|
"album",
|
||||||
|
"albums",
|
||||||
|
"musicbrainz_id",
|
||||||
|
"genre",
|
||||||
|
"tags",
|
||||||
|
"base_run_time_seconds",
|
||||||
|
"created",
|
||||||
|
"modified",
|
||||||
|
]
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0031_alter_track_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="track",
|
||||||
|
old_name="artist",
|
||||||
|
new_name="artist_fk",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="track",
|
||||||
|
name="artist_fk",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
to="music.artist",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="track",
|
||||||
|
name="artists",
|
||||||
|
field=models.ManyToManyField(to="music.artist"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_artist_fk_to_artists(apps, schema_editor):
|
||||||
|
Track = apps.get_model("music", "Track")
|
||||||
|
TrackArtist = Track.artists.through
|
||||||
|
TrackArtist.objects.bulk_create(
|
||||||
|
[
|
||||||
|
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
|
||||||
|
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
|
||||||
|
],
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0032_track_artist_fk_and_artists"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_artist_fk_to_artists,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -124,7 +124,7 @@ class Artist(TimeStampedModel):
|
|||||||
def charts(self):
|
def charts(self):
|
||||||
from scrobbles.models import ChartRecord
|
from scrobbles.models import ChartRecord
|
||||||
|
|
||||||
return ChartRecord.objects.filter(track__artist=self).order_by("-year")
|
return ChartRecord.objects.filter(track__artists=self).order_by("-year")
|
||||||
|
|
||||||
def scrape_allmusic(self, force=False) -> None:
|
def scrape_allmusic(self, force=False) -> None:
|
||||||
if not self.allmusic_id or force:
|
if not self.allmusic_id or force:
|
||||||
@ -422,7 +422,8 @@ class Album(TimeStampedModel):
|
|||||||
self.artists.add(new_artist)
|
self.artists.add(new_artist)
|
||||||
if not new_artist:
|
if not new_artist:
|
||||||
for t in self.track_set.all():
|
for t in self.track_set.all():
|
||||||
self.artists.add(t.artist)
|
for a in t.artists.all():
|
||||||
|
self.artists.add(a)
|
||||||
if not self.cover_image or self.cover_image == "default-image-replace-me":
|
if not self.cover_image or self.cover_image == "default-image-replace-me":
|
||||||
self.fetch_artwork()
|
self.fetch_artwork()
|
||||||
self.fix_album_artist()
|
self.fix_album_artist()
|
||||||
@ -573,7 +574,10 @@ class Album(TimeStampedModel):
|
|||||||
class Track(ScrobblableMixin):
|
class Track(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||||
|
|
||||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
artist_fk = models.ForeignKey(
|
||||||
|
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
|
||||||
|
)
|
||||||
|
artists = models.ManyToManyField(Artist)
|
||||||
albums = models.ManyToManyField(Album, related_name="tracks")
|
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
@ -581,6 +585,10 @@ class Track(ScrobblableMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["album", "musicbrainz_id"]]
|
unique_together = [["album", "musicbrainz_id"]]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def artist(self):
|
||||||
|
return self.artists.first() or self.artist_fk
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} by {self.artist}"
|
return f"{self.title} by {self.artist}"
|
||||||
|
|
||||||
@ -615,8 +623,9 @@ class Track(ScrobblableMixin):
|
|||||||
@property
|
@property
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
url = ""
|
url = ""
|
||||||
if self.artist.thumbnail:
|
primary_artist = self.artist
|
||||||
url = self.artist.thumbnail_medium.url
|
if primary_artist and primary_artist.thumbnail:
|
||||||
|
url = primary_artist.thumbnail_medium.url
|
||||||
if self.primary_album and self.primary_album.cover_image:
|
if self.primary_album and self.primary_album.cover_image:
|
||||||
url = self.primary_album.cover_image_medium.url
|
url = self.primary_album.cover_image_medium.url
|
||||||
return url
|
return url
|
||||||
@ -625,6 +634,7 @@ class Track(ScrobblableMixin):
|
|||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls,
|
cls,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
|
artist_names: list[str] | None = None,
|
||||||
artist_name: str = "",
|
artist_name: str = "",
|
||||||
album_name: str = "",
|
album_name: str = "",
|
||||||
run_time_seconds: int | None = None,
|
run_time_seconds: int | None = None,
|
||||||
@ -638,25 +648,40 @@ class Track(ScrobblableMixin):
|
|||||||
name
|
name
|
||||||
|
|
||||||
Optionally, we can update any found artists with overwrite."""
|
Optionally, we can update any found artists with overwrite."""
|
||||||
|
from music.utils import parse_artist_names
|
||||||
|
|
||||||
|
if artist_names is None and artist_name:
|
||||||
|
artist_names = parse_artist_names(artist_name)
|
||||||
|
|
||||||
|
if not artist_names:
|
||||||
|
artist_names = []
|
||||||
|
|
||||||
album = None
|
album = None
|
||||||
if album_name:
|
if album_name:
|
||||||
logger.info(f"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)
|
first_name = artist_names[0] if artist_names else ""
|
||||||
artist = album.album_artist
|
album = Album.find_or_create(name=album_name, artist_name=first_name)
|
||||||
else:
|
|
||||||
artist = Artist.find_or_create(artist_name, track_name=title)
|
artist_objs = []
|
||||||
if not artist:
|
for name in artist_names:
|
||||||
artist = Artist.find_or_create(artist_name)
|
artist = Artist.find_or_create(name, track_name=title)
|
||||||
|
if artist:
|
||||||
|
artist_objs.append(artist)
|
||||||
|
|
||||||
|
track = None
|
||||||
|
if artist_objs:
|
||||||
|
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
|
||||||
|
|
||||||
lookup_keys = {"title": title, "artist": artist}
|
|
||||||
if run_time_seconds:
|
|
||||||
lookup_keys["base_run_time_seconds"] = run_time_seconds
|
|
||||||
logger.info(f"Looking up track using: {lookup_keys}")
|
|
||||||
track = cls.objects.filter(**lookup_keys).first()
|
|
||||||
if not track:
|
if not track:
|
||||||
track = cls.objects.filter(title=title, artist=artist).first()
|
track = cls.objects.filter(title=title).first()
|
||||||
if not track:
|
|
||||||
track, _ = cls.objects.get_or_create(title=title, artist=artist)
|
if not track:
|
||||||
|
track = cls(title=title)
|
||||||
|
track.save()
|
||||||
|
track.refresh_from_db()
|
||||||
|
|
||||||
|
if artist_objs:
|
||||||
|
track.artists.add(*artist_objs)
|
||||||
|
|
||||||
if album:
|
if album:
|
||||||
track.albums.add(album)
|
track.albums.add(album)
|
||||||
@ -666,17 +691,18 @@ class Track(ScrobblableMixin):
|
|||||||
track.base_run_time_seconds = run_time_seconds
|
track.base_run_time_seconds = run_time_seconds
|
||||||
track.musicbrainz_id = mbid
|
track.musicbrainz_id = mbid
|
||||||
else:
|
else:
|
||||||
|
artist_name_str = " & ".join(artist_names) if artist_names else ""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Enriching track {track}",
|
f"Enriching track {track}",
|
||||||
extra={
|
extra={
|
||||||
"title": title,
|
"title": title,
|
||||||
"artist_name": artist_name,
|
"artist_name": artist_name_str,
|
||||||
"track_id": track.id,
|
"track_id": track.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
mbid, length = get_recording_mbid_exact(
|
mbid, length = get_recording_mbid_exact(
|
||||||
title, artist_name, album_name
|
title, artist_name_str, album_name
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
print("No musicbrainz result found, cannot enrich")
|
print("No musicbrainz result found, cannot enrich")
|
||||||
@ -688,6 +714,4 @@ class Track(ScrobblableMixin):
|
|||||||
|
|
||||||
return track
|
return track
|
||||||
|
|
||||||
def fix_metadata(self, force_update=False):
|
def fix_metadata(self, force_update=False): ...
|
||||||
|
|
||||||
...
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
|||||||
@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
|
|||||||
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
|
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
|
||||||
if " featuring " in name.lower():
|
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() and "of the wand" not in name.lower():
|
|
||||||
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
|
||||||
|
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_artist_names(name: str) -> list[str]:
|
||||||
|
"""Split a combined artist string (e.g. 'A & B') into individual names.
|
||||||
|
|
||||||
|
First strips feat./featuring/w. prefixes, then splits on ' & ' to
|
||||||
|
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
|
||||||
|
"""
|
||||||
|
name = clean_artist_name(name)
|
||||||
|
if " & " in name.lower():
|
||||||
|
return [
|
||||||
|
part.strip()
|
||||||
|
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
return [name]
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_various_artists() -> "Artist":
|
def get_or_create_various_artists() -> "Artist":
|
||||||
from music.models import Artist
|
from music.models import Artist
|
||||||
|
|
||||||
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
|
|||||||
from music.models import Track
|
from music.models import Track
|
||||||
|
|
||||||
duplicates = (
|
duplicates = (
|
||||||
Track.objects.values("artist", "title")
|
Track.objects.values("title")
|
||||||
.annotate(dup_count=models.Count("id"))
|
.annotate(dup_count=models.Count("id"))
|
||||||
.filter(dup_count__gt=1)
|
.filter(dup_count__gt=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
query = models.Q()
|
query = models.Q()
|
||||||
for dup in duplicates:
|
for dup in duplicates:
|
||||||
query |= models.Q(artist=dup["artist"], title=dup["title"])
|
query |= models.Q(title=dup["title"])
|
||||||
|
|
||||||
duplicate_tracks = Track.objects.filter(query)
|
duplicate_tracks = Track.objects.filter(query)
|
||||||
|
|
||||||
for b in duplicate_tracks:
|
for b in duplicate_tracks:
|
||||||
tracks = Track.objects.filter(artist=b.artist, title=b.title)
|
tracks = Track.objects.filter(title=b.title)
|
||||||
first = tracks.first()
|
first = tracks.first()
|
||||||
for other in tracks.exclude(id=first.id):
|
for other in tracks.exclude(id=first.id):
|
||||||
print("Moving scrobbles for", other.id, " to ", first.id)
|
print("Moving scrobbles for", other.id, " to ", first.id)
|
||||||
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
|
|||||||
for track in Track.objects.all():
|
for track in Track.objects.all():
|
||||||
albums_to_add = []
|
albums_to_add = []
|
||||||
duplicates = (
|
duplicates = (
|
||||||
Track.objects.filter(title=track.title, artist=track.artist)
|
Track.objects.filter(title=track.title)
|
||||||
.exclude(id=track.id)
|
.exclude(id=track.id)
|
||||||
.exclude(id__in=processed_ids)
|
.exclude(id__in=processed_ids)
|
||||||
)
|
)
|
||||||
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
|
|||||||
track.albums.add(dup_track.album)
|
track.albums.add(dup_track.album)
|
||||||
|
|
||||||
# Find out if this track appears more than once
|
# Find out if this track appears more than once
|
||||||
duplicates = Track.objects.filter(title=track.title, artist=track.artist)
|
duplicates = Track.objects.filter(title=track.title)
|
||||||
if duplicates.count() > 1:
|
if duplicates.count() > 1:
|
||||||
logger.info(f"Track appears more than once, condensing: {track}")
|
logger.info(f"Track appears more than once, condensing: {track}")
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
|
from charts.models import ChartRecord
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
from music.models import Album, Artist, Track
|
from music.models import Album, Artist, Track
|
||||||
from charts.models import ChartRecord
|
|
||||||
from scrobbles.stats import get_scrobble_count_qs
|
from scrobbles.stats import get_scrobble_count_qs
|
||||||
|
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
|
||||||
|
|
||||||
|
|
||||||
class TrackListView(ScrobbleableListView):
|
class TrackListView(ScrobbleableListView):
|
||||||
@ -64,7 +63,7 @@ class ArtistDetailView(generic.DetailView):
|
|||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
context_data["recent_scrobbles"] = (
|
context_data["recent_scrobbles"] = (
|
||||||
Scrobble.objects.filter(track__artist=artist)
|
Scrobble.objects.filter(track__artists=artist)
|
||||||
.select_related("track", "track__album")
|
.select_related("track", "track__album")
|
||||||
.order_by("-timestamp")[:100]
|
.order_by("-timestamp")[:100]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import csv
|
import csv
|
||||||
import tempfile
|
import tempfile
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||||
|
|||||||
@ -564,7 +564,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
return self.select_related("user").prefetch_related(
|
return self.select_related("user").prefetch_related(
|
||||||
"video",
|
"video",
|
||||||
"track",
|
"track",
|
||||||
"track__artist",
|
"track__artist_fk",
|
||||||
"podcast_episode",
|
"podcast_episode",
|
||||||
"podcast_episode__podcast",
|
"podcast_episode__podcast",
|
||||||
"sport_event",
|
"sport_event",
|
||||||
|
|||||||
@ -226,7 +226,7 @@ class RecentScrobbleList(ListView):
|
|||||||
|
|
||||||
# Get user's home scrobble limit (default 20)
|
# Get user's home scrobble limit (default 20)
|
||||||
home_limit = 20
|
home_limit = 20
|
||||||
if hasattr(user, 'profile') and user.profile.home_scrobble_limit:
|
if hasattr(user, "profile") and user.profile.home_scrobble_limit:
|
||||||
home_limit = user.profile.home_scrobble_limit
|
home_limit = user.profile.home_scrobble_limit
|
||||||
|
|
||||||
today = timezone.localtime(timezone.now())
|
today = timezone.localtime(timezone.now())
|
||||||
@ -612,9 +612,7 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
|
|||||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||||
|
|
||||||
|
|
||||||
class ScaleCSVImportCreateView(
|
class ScaleCSVImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
|
||||||
LoginRequiredMixin, JsonableResponseMixin, CreateView
|
|
||||||
):
|
|
||||||
model = ScaleCSVImport
|
model = ScaleCSVImport
|
||||||
fields = ["csv_file"]
|
fields = ["csv_file"]
|
||||||
template_name = "scrobbles/upload_form.html"
|
template_name = "scrobbles/upload_form.html"
|
||||||
@ -623,17 +621,13 @@ class ScaleCSVImportCreateView(
|
|||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.object = form.save(commit=False)
|
self.object = form.save(commit=False)
|
||||||
self.object.user = self.request.user
|
self.object.user = self.request.user
|
||||||
self.object.original_filename = (
|
self.object.original_filename = form.cleaned_data["csv_file"].name
|
||||||
form.cleaned_data["csv_file"].name
|
|
||||||
)
|
|
||||||
self.object.save()
|
self.object.save()
|
||||||
self.object.process()
|
self.object.process()
|
||||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||||
|
|
||||||
|
|
||||||
class TrailGPXImportCreateView(
|
class TrailGPXImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
|
||||||
LoginRequiredMixin, JsonableResponseMixin, CreateView
|
|
||||||
):
|
|
||||||
model = TrailGPXImport
|
model = TrailGPXImport
|
||||||
fields = ["gpx_file"]
|
fields = ["gpx_file"]
|
||||||
template_name = "scrobbles/upload_form.html"
|
template_name = "scrobbles/upload_form.html"
|
||||||
@ -852,7 +846,9 @@ def scrobble_start(request, uuid):
|
|||||||
book=media_obj,
|
book=media_obj,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
.filter(Q(long_play_complete=False) | Q(long_play_complete__isnull=True))
|
.filter(
|
||||||
|
Q(long_play_complete=False) | Q(long_play_complete__isnull=True)
|
||||||
|
)
|
||||||
.filter(log__page_end__isnull=False)
|
.filter(log__page_end__isnull=False)
|
||||||
.order_by("-timestamp")
|
.order_by("-timestamp")
|
||||||
.first()
|
.first()
|
||||||
@ -1328,6 +1324,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def _day_color(self, month_index, day_num, total_days):
|
def _day_color(self, month_index, day_num, total_days):
|
||||||
import colorsys
|
import colorsys
|
||||||
|
|
||||||
hue = (month_index - 1) * 30 / 360
|
hue = (month_index - 1) * 30 / 360
|
||||||
lightness = 0.80 + (day_num / total_days) * 0.15
|
lightness = 0.80 + (day_num / total_days) * 0.15
|
||||||
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
|
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
|
||||||
@ -1355,7 +1352,9 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES:
|
if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES:
|
||||||
active_types = [media_type_filter]
|
active_types = [media_type_filter]
|
||||||
else:
|
else:
|
||||||
active_types = [t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE]
|
active_types = [
|
||||||
|
t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE
|
||||||
|
]
|
||||||
|
|
||||||
scrobbles = (
|
scrobbles = (
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
@ -1364,12 +1363,23 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
timestamp__date__lte=month_end,
|
timestamp__date__lte=month_end,
|
||||||
media_type__in=active_types,
|
media_type__in=active_types,
|
||||||
)
|
)
|
||||||
.select_related("task", "birding_location", "food", "trail", "video_game", "book", "mood", "video", "board_game")
|
.select_related(
|
||||||
|
"task",
|
||||||
|
"birding_location",
|
||||||
|
"food",
|
||||||
|
"trail",
|
||||||
|
"video_game",
|
||||||
|
"book",
|
||||||
|
"mood",
|
||||||
|
"video",
|
||||||
|
"board_game",
|
||||||
|
)
|
||||||
.order_by("timestamp")
|
.order_by("timestamp")
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.db.models.functions import TruncDate
|
from django.db.models.functions import TruncDate
|
||||||
|
|
||||||
total_by_day = dict(
|
total_by_day = dict(
|
||||||
Scrobble.objects.filter(
|
Scrobble.objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@ -1377,7 +1387,11 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
timestamp__date__lte=month_end,
|
timestamp__date__lte=month_end,
|
||||||
)
|
)
|
||||||
.exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True))
|
.exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True))
|
||||||
.annotate(local_date=TruncDate("timestamp", tzinfo=timezone.get_current_timezone()))
|
.annotate(
|
||||||
|
local_date=TruncDate(
|
||||||
|
"timestamp", tzinfo=timezone.get_current_timezone()
|
||||||
|
)
|
||||||
|
)
|
||||||
.values("local_date")
|
.values("local_date")
|
||||||
.annotate(count=Count("id"))
|
.annotate(count=Count("id"))
|
||||||
.values_list("local_date", "count")
|
.values_list("local_date", "count")
|
||||||
@ -1403,17 +1417,27 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
{
|
{
|
||||||
"uuid": scrobble.uuid,
|
"uuid": scrobble.uuid,
|
||||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||||
"title": str(scrobble.media_obj) if scrobble.media_obj else scrobble.media_type,
|
"title": (
|
||||||
|
str(scrobble.media_obj)
|
||||||
|
if scrobble.media_obj
|
||||||
|
else scrobble.media_type
|
||||||
|
),
|
||||||
"media_type": scrobble.media_type,
|
"media_type": scrobble.media_type,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
calendar_days.append({
|
calendar_days.append(
|
||||||
"day": day_num,
|
{
|
||||||
"scrobbles": day_scrobbles,
|
"day": day_num,
|
||||||
"total_count": total_by_day.get(datetime(year, month, day_num).date(), 0),
|
"scrobbles": day_scrobbles,
|
||||||
"is_today": year == today.year and month == today.month and day_num == today.day,
|
"total_count": total_by_day.get(
|
||||||
"color": self._day_color(month, day_num, total_days),
|
datetime(year, month, day_num).date(), 0
|
||||||
})
|
),
|
||||||
|
"is_today": year == today.year
|
||||||
|
and month == today.month
|
||||||
|
and day_num == today.day,
|
||||||
|
"color": self._day_color(month, day_num, total_days),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
@ -1430,7 +1454,10 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||||
"month_color": month_color,
|
"month_color": month_color,
|
||||||
"active_filter": media_type_filter or "",
|
"active_filter": media_type_filter or "",
|
||||||
"media_types": [{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")} for mt in self.CALENDAR_MEDIA_TYPES],
|
"media_types": [
|
||||||
|
{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")}
|
||||||
|
for mt in self.CALENDAR_MEDIA_TYPES
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return ctx
|
return ctx
|
||||||
@ -1441,7 +1468,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
MEDIA_FIELDS = {
|
MEDIA_FIELDS = {
|
||||||
"Video": ["video__title", "video__overview"],
|
"Video": ["video__title", "video__overview"],
|
||||||
"Track": ["track__title", "track__artist__name", "track__album__name"],
|
"Track": ["track__title", "track__artists__name", "track__album__name"],
|
||||||
"PodcastEpisode": ["podcast_episode__title", None],
|
"PodcastEpisode": ["podcast_episode__title", None],
|
||||||
"Book": ["book__title", "book__summary"],
|
"Book": ["book__title", "book__summary"],
|
||||||
"Paper": ["paper__title", None],
|
"Paper": ["paper__title", None],
|
||||||
|
|||||||
Reference in New Issue
Block a user