362 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			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)
 |