funkwhale/api/funkwhale_api/playlists/models.py

362 lines
13 KiB
Python

import datetime
import uuid
from django.db import models, transaction
from django.db.models import Q
from django.db.models.expressions import OuterRef, Subquery
from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
class PlaylistQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def with_tracks_count(self):
return self.annotate(_tracks_count=models.Count("playlist_tracks"))
def with_duration(self):
subquery = Subquery(
music_models.Upload.objects.filter(
track_id=OuterRef("playlist_tracks__track__id")
)
.order_by("id")
.values("id")[:1]
)
return self.annotate(
duration=models.Sum(
"playlist_tracks__track__uploads__duration",
filter=Q(playlist_tracks__track__uploads=subquery),
)
)
def with_covers(self):
album_prefetch = models.Prefetch(
"album",
queryset=music_models.Album.objects.select_related("attachment_cover"),
)
track_prefetch = models.Prefetch(
"track",
queryset=music_models.Track.objects.prefetch_related(album_prefetch).only(
"id", "album_id"
),
)
plt_prefetch = models.Prefetch(
"playlist_tracks",
queryset=PlaylistTrack.objects.all()
.exclude(track__album__attachment_cover=None)
.order_by("index")
.only("id", "playlist_id", "track_id")
.prefetch_related(track_prefetch),
to_attr="plts_for_cover",
)
return self.prefetch_related(plt_prefetch)
def with_playable_plts(self, actor):
return self.prefetch_related(
models.Prefetch(
"playlist_tracks",
queryset=PlaylistTrack.objects.playable_by(actor),
to_attr="playable_plts",
)
)
def playable_by(self, actor, include=True):
plts = PlaylistTrack.objects.playable_by(actor, include)
if include:
return self.filter(playlist_tracks__in=plts).distinct()
else:
return self.exclude(playlist_tracks__in=plts).distinct()
class Playlist(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
name = models.CharField(max_length=100)
actor = models.ForeignKey(
"federation.Actor", related_name="playlists", on_delete=models.CASCADE
)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
privacy_level = fields.get_privacy_field()
description = models.TextField(max_length=5000, null=True, blank=True)
objects = PlaylistQuerySet.as_manager()
federation_namespace = "playlists"
def __str__(self):
return self.name
def get_absolute_url(self):
return f"/library/playlists/{self.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)
@transaction.atomic
def insert(self, plt, index=None, allow_duplicates=True):
"""
Given a PlaylistTrack, insert it at the correct index in the playlist,
and update other tracks index if necessary.
"""
old_index = plt.index
move = old_index is not None
if index is not None and index == old_index:
# moving at same position, just skip
return index
existing = self.playlist_tracks.select_for_update()
if move:
existing = existing.exclude(pk=plt.pk)
total = existing.filter(index__isnull=False).count()
if index is None:
# we simply increment the last track index by 1
index = total
if index > total:
raise exceptions.ValidationError("Index is not continuous")
if index < 0:
raise exceptions.ValidationError("Index must be zero or positive")
if not allow_duplicates:
existing_without_current_plt = existing.exclude(pk=plt.pk)
self._check_duplicate_add(existing_without_current_plt, [plt.track])
if move:
# we remove the index temporarily, to avoid integrity errors
plt.index = None
plt.save(update_fields=["index"])
if index > old_index:
# new index is higher than current, we decrement previous tracks
to_update = existing.filter(index__gt=old_index, index__lte=index)
to_update.update(index=models.F("index") - 1)
if index < old_index:
# new index is lower than current, we increment next tracks
to_update = existing.filter(index__lt=old_index, index__gte=index)
to_update.update(index=models.F("index") + 1)
else:
to_update = existing.filter(index__gte=index)
to_update.update(index=models.F("index") + 1)
plt.index = index
plt.save(update_fields=["index"])
self.save(update_fields=["modification_date"])
return index
@transaction.atomic
def remove(self, index):
existing = self.playlist_tracks.select_for_update()
self.save(update_fields=["modification_date"])
to_update = existing.filter(index__gt=index)
return to_update.update(index=models.F("index") - 1)
@transaction.atomic
def insert_many(self, tracks, allow_duplicates=True):
existing = self.playlist_tracks.select_for_update()
now = timezone.now()
total = existing.filter(index__isnull=False).count()
max_tracks = preferences.get("playlists__max_tracks")
if existing.count() + len(tracks) > max_tracks:
raise exceptions.ValidationError(
f"Playlist would reach the maximum of {max_tracks} tracks"
)
if not allow_duplicates:
self._check_duplicate_add(existing, tracks)
self.save(update_fields=["modification_date"])
start = total
plts = [
PlaylistTrack(
creation_date=now,
playlist=self,
track=track,
index=start + i,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": new_uuid}, # Use the newly generated UUID
)
),
)
for i, track in enumerate(tracks)
]
return PlaylistTrack.objects.bulk_create(plts)
def _check_duplicate_add(self, existing_playlist_tracks, tracks_to_add):
track_ids = [t.pk for t in tracks_to_add]
duplicates = existing_playlist_tracks.filter(
track__pk__in=track_ids
).values_list("track__pk", flat=True)
if duplicates:
duplicate_tracks = [t for t in tracks_to_add if t.pk in duplicates]
raise exceptions.ValidationError(
{
"non_field_errors": [
{
"tracks": duplicate_tracks,
"playlist_name": self.name,
"code": "tracks_already_exist_in_playlist",
}
]
}
)
def schedule_scan(self, actor, force=False):
"""Update playlist tracks if playlist is a remote one. If it's a local playlist it send an update activity
on the remote server which will trigger a scan"""
latest_scan = (
self.scans.exclude(status="errored").order_by("-creation_date").first()
)
delay_between_scans = datetime.timedelta(seconds=3600 * 24)
now = timezone.now()
if (
not force
and latest_scan
and latest_scan.creation_date + delay_between_scans > now
):
return
from . import tasks
scan = self.scans.create(
total_files=len(self.playlist_tracks.all()), actor=actor
)
if self.actor.is_local:
from funkwhale_api.federation import routes
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Playlist"}},
context={"playlist": self, "actor": self.actor},
)
scan.status = "finished"
return scan
else:
common_utils.on_commit(
tasks.start_playlist_scan.delay, playlist_scan_id=scan.pk
)
return scan
class PlaylistTrackQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def for_nested_serialization(self, actor=None):
tracks = music_models.Track.objects.with_playable_uploads(actor)
tracks = tracks.prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
"album__attachment_cover",
"attributed_to",
)
return self.prefetch_related(
models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
)
def annotate_playable_by_actor(self, actor):
tracks = (
music_models.Upload.objects.playable_by(actor)
.filter(track__pk=models.OuterRef("track"))
.order_by("id")
.values("id")[:1]
)
subquery = models.Subquery(tracks)
return self.annotate(is_playable_by_actor=subquery)
def playable_by(self, actor, include=True):
tracks = music_models.Track.objects.playable_by(actor, include)
if include:
return self.filter(track__pk__in=tracks).distinct()
else:
return self.exclude(track__pk__in=tracks).distinct()
def by_index(self, index):
plts = self.order_by("index").values_list("id", flat=True)
try:
plt_id = plts[index]
except IndexError:
raise PlaylistTrack.DoesNotExist
return PlaylistTrack.objects.get(pk=plt_id)
class PlaylistTrack(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
track = models.ForeignKey(
"music.Track", related_name="playlist_tracks", on_delete=models.CASCADE
)
index = models.PositiveIntegerField(null=True, blank=True)
playlist = models.ForeignKey(
Playlist, related_name="playlist_tracks", on_delete=models.CASCADE
)
creation_date = models.DateTimeField(default=timezone.now)
objects = PlaylistTrackQuerySet.as_manager()
federation_namespace = "playlist-tracks"
class Meta:
ordering = ("-playlist", "index")
def delete(self, *args, **kwargs):
playlist = self.playlist
index = self.index
update_indexes = kwargs.pop("update_indexes", False)
r = super().delete(*args, **kwargs)
if index is not None and update_indexes:
playlist.remove(index)
return r
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)
class PlaylistScan(models.Model):
actor = models.ForeignKey(
"federation.Actor", null=True, blank=True, on_delete=models.CASCADE
)
playlist = models.ForeignKey(
Playlist, related_name="scans", on_delete=models.CASCADE
)
total_files = models.PositiveIntegerField(default=0)
processed_files = models.PositiveIntegerField(default=0)
errored_files = models.PositiveIntegerField(default=0)
status = models.CharField(default="pending", max_length=25)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(null=True, blank=True)