Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 278-search-browse
  • 303-json-ld
  • 316-ultrasonic
  • 334-don-t-display-an-empty-page-browser
  • 463-user-libraries
  • 463-user-libraries-full
  • ButterflyOfFire/funkwhale-patch-1
  • avatar-everywhere
  • develop
  • master
  • playlist-component
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
49 results
Show changes
# Generated by Django 4.2.9 on 2024-12-03 11:28
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
("federation", "0028_auto_20221027_1141"),
("playlists", "0006_playlisttrack_fid_playlisttrack_url_and_more"),
]
operations = [
migrations.AlterField(
model_name="playlist",
name="actor",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playlists",
to="federation.actor",
),
),
migrations.AlterField(
model_name="playlisttrack",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.CreateModel(
name="PlaylistScan",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("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=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(blank=True, null=True)),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="federation.actor",
),
),
(
"playlist",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scans",
to="playlists.playlist",
),
),
],
),
]
# Generated by Django 4.2.9 on 2025-01-03 16:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"),
]
operations = [
migrations.AlterField(
model_name="playlist",
name="name",
field=models.CharField(max_length=100),
),
migrations.AddField(
model_name="playlist",
name="description",
field=models.TextField(blank=True, max_length=5000, null=True),
),
]
import django.db.models.deletion
from django.db import migrations, models, transaction
from funkwhale_api.federation import utils as federation_utils
from django.urls import reverse
import uuid
from django.conf import settings
def add_uploads_to_pl_library(playlist, library):
for plt in playlist.playlist_tracks.all():
for upload in plt.track.uploads.filter(library__actor=playlist.actor):
library.uploads.add(upload)
@transaction.atomic
def create_playlist_libraries(apps, schema_editor):
Playlist = apps.get_model("playlists", "Playlist")
Library = apps.get_model("music", "Library")
Actor = apps.get_model("federation", "Actor")
playlist_with_lib_count = 0
for playlist in Playlist.objects.all():
if not federation_utils.is_local(playlist.actor.fid):
continue
library = playlist.library
if not library:
try:
# we don't want to get_or_create in case it's a channel lib
library = Library.objects.create(
name="playlist_" + playlist.name,
privacy_level="me",
actor=playlist.actor,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:libraries-detail",
kwargs={"uuid": new_uuid},
)
),
)
library.save()
playlist.library = library
playlist.save()
with transaction.atomic():
add_uploads_to_pl_library(playlist, library)
except Exception as e:
print(
f"An error occurred during playlist.library creation, raising since we want\
to enforce one lib per playlist"
)
raise e
playlist_with_lib_count = playlist_with_lib_count + 1
local_actors = Actor.objects.filter(domain_id=settings.FEDERATION_HOSTNAME)
if (
Library.objects.filter(
playlist__isnull=False, actor__in=local_actors
).count()
!= playlist_with_lib_count
):
raise Exception(
"Should have the same amount of local playlist and libraries with playlist"
)
class Migration(migrations.Migration):
dependencies = [
("playlists", "0008_playlist_library_drop"),
]
operations = [
migrations.AddField(
model_name="playlist",
name="library",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="playlist",
to="music.library",
),
),
migrations.RunPython(
create_playlist_libraries, reverse_code=migrations.RunPython.noop
),
]
# Generated by Django 5.1.6 on 2025-09-12 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlists", "0009_playlist_library"),
]
operations = [
migrations.AlterField(
model_name="playlist",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
max_length=30,
default="me",
),
),
]
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, preferences
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):
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__files__duration")
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.only("cover")
"album",
queryset=music_models.Album.objects.select_related("attachment_cover"),
)
track_prefetch = models.Prefetch(
"track",
......@@ -29,8 +51,7 @@ class PlaylistQuerySet(models.QuerySet):
plt_prefetch = models.Prefetch(
"playlist_tracks",
queryset=PlaylistTrack.objects.all()
.exclude(track__album__cover=None)
.exclude(track__album__cover="")
.exclude(track__album__attachment_cover=None)
.order_by("index")
.only("id", "playlist_id", "track_id")
.prefetch_related(track_prefetch),
......@@ -38,23 +59,82 @@ class PlaylistQuerySet(models.QuerySet):
)
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(models.Model):
name = models.CharField(max_length=50)
user = models.ForeignKey(
"users.User", related_name="playlists", on_delete=models.CASCADE
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"
library = models.OneToOneField(
"music.Library",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="playlist",
)
def __str__(self):
return self.name
def get_absolute_url(self):
return f"/library/playlists/{self.uuid}"
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()
if not self.pk and not self.library_id:
self.library = music_models.Library.objects.create(
actor=self.actor,
name="playlist_" + self.name,
privacy_level="me",
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:libraries-detail", kwargs={"uuid": new_uuid}
),
),
)
if not self.privacy_level:
self.privacy_level = self.actor.user.privacy_level
return super().save(**kwargs)
@transaction.atomic
def insert(self, plt, index=None):
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.
......@@ -80,6 +160,10 @@ class Playlist(models.Model):
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
......@@ -109,38 +193,140 @@ class Playlist(models.Model):
return to_update.update(index=models.F("index") - 1)
@transaction.atomic
def insert_many(self, tracks):
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(
"Playlist would reach the maximum of {} tracks".format(max_tracks)
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
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]
class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self):
return (
self.select_related()
.select_related("track__album__artist")
.prefetch_related(
"track__tags", "track__files", "track__artist__albums__tracks__tags"
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=1)
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)
if include:
return self.filter(track__pk__in=tracks).distinct()
else:
return self.exclude(track__pk__in=tracks).distinct()
class PlaylistTrack(models.Model):
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
)
......@@ -151,10 +337,10 @@ class PlaylistTrack(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
objects = PlaylistTrackQuerySet.as_manager()
federation_namespace = "playlist-tracks"
class Meta:
ordering = ("-playlist", "index")
unique_together = ("playlist", "index")
def delete(self, *args, **kwargs):
playlist = self.playlist
......@@ -164,3 +350,37 @@ class PlaylistTrack(models.Model):
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)
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
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)
from defusedxml.ElementTree import parse
from rest_framework.parsers import BaseParser
# from https://github.com/jpadilla/django-rest-framework-xml/blob/master/rest_framework_xml/parsers.py
class XspfParser(BaseParser):
"""
Takes a xspf stream, validate it, and return an xspf json
"""
media_type = "application/octet-stream"
def parse(self, stream, media_type=None, parser_context=None):
playlist = {"tracks": []}
tree = parse(stream, forbid_dtd=True)
root = tree.getroot()
# Extract playlist information
playlist_info = root.find(".")
if playlist_info is not None:
playlist["title"] = playlist_info.findtext(
"{http://xspf.org/ns/0/}title", default=""
)
playlist["creator"] = playlist_info.findtext(
"{http://xspf.org/ns/0/}creator", default=""
)
playlist["creation_date"] = playlist_info.findtext(
"{http://xspf.org/ns/0/}date", default=""
)
playlist["version"] = playlist_info.attrib.get("version", "")
# Extract track information
for track in root.findall(".//{http://xspf.org/ns/0/}track"):
track_info = {
"location": track.findtext(
"{http://xspf.org/ns/0/}location", default=""
),
"title": track.findtext("{http://xspf.org/ns/0/}title", default=""),
"creator": track.findtext("{http://xspf.org/ns/0/}creator", default=""),
"album": track.findtext("{http://xspf.org/ns/0/}album", default=""),
"duration": track.findtext(
"{http://xspf.org/ns/0/}duration", default=""
),
}
playlist["tracks"].append(track_info)
return playlist
import xml.etree.ElementTree as etree
from xml.etree.ElementTree import Element, SubElement
from defusedxml import minidom
from rest_framework import renderers
from funkwhale_api.playlists.models import Playlist
class PlaylistXspfRenderer(renderers.BaseRenderer):
media_type = "application/octet-stream"
format = "xspf"
def render(self, data, accepted_media_type=None, renderer_context=None):
if isinstance(data, bytes):
return data
fw_playlist = Playlist.objects.get(uuid=data["uuid"])
plt_tracks = fw_playlist.playlist_tracks.prefetch_related("track")
top = Element("playlist", version="1", xmlns="http://xspf.org/ns/0/")
title_xspf = SubElement(top, "title")
title_xspf.text = fw_playlist.name
date_xspf = SubElement(top, "date")
date_xspf.text = fw_playlist.creation_date.isoformat()
trackList_xspf = SubElement(top, "trackList")
for plt_track in plt_tracks:
track = plt_track.track
write_xspf_track_data(track, trackList_xspf)
return prettify(top)
def write_xspf_track_data(track, trackList_xspf):
"""
Insert a track into the trackList subelement of a xspf file
"""
track_xspf = SubElement(trackList_xspf, "track")
location_xspf = SubElement(track_xspf, "location")
location_xspf.text = "https://" + track.domain_name + track.listen_url
title_xspf = SubElement(track_xspf, "title")
title_xspf.text = str(track.title)
creator_xspf = SubElement(track_xspf, "creator")
creator_xspf.text = str(track.get_artist_credit_string)
if str(track.album) == "[non-album tracks]":
return
else:
album_xspf = SubElement(track_xspf, "album")
album_xspf.text = str(track.album)
def prettify(elem):
"""
Return a pretty-printed XML string for the Element.
"""
rough_string = etree.tostring(elem, "utf-8")
reparsed = minidom.parseString(rough_string)
return reparsed.toprettyxml(indent=" ")
from django.db import transaction
import logging
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.music.models import Track
from funkwhale_api.common.fields import PRIVACY_LEVEL_CHOICES
from funkwhale_api.federation.api_serializers import FullActorSerializer
from funkwhale_api.music import tasks
from funkwhale_api.music.models import Album, Artist, Track
from funkwhale_api.music.serializers import TrackSerializer
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models
logger = logging.getLogger(__name__)
class PlaylistTrackSerializer(serializers.ModelSerializer):
track = TrackSerializer()
# track = TrackSerializer()
track = serializers.SerializerMethodField()
class Meta:
model = models.PlaylistTrack
fields = ("id", "track", "playlist", "index", "creation_date")
fields = ("track", "index", "creation_date")
class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
index = serializers.IntegerField(required=False, min_value=0, allow_null=True)
class Meta:
model = models.PlaylistTrack
fields = ("id", "track", "playlist", "index")
def validate_playlist(self, value):
if self.context.get("request"):
# validate proper ownership on the playlist
if self.context["request"].user != value.user:
raise serializers.ValidationError(
"You do not have the permission to edit this playlist"
)
existing = value.playlist_tracks.count()
max_tracks = preferences.get("playlists__max_tracks")
if existing >= max_tracks:
raise serializers.ValidationError(
"Playlist has reached the maximum of {} tracks".format(max_tracks)
)
return value
@transaction.atomic
def create(self, validated_data):
index = validated_data.pop("index", None)
instance = super().create(validated_data)
instance.playlist.insert(instance, index)
return instance
@transaction.atomic
def update(self, instance, validated_data):
update_index = "index" in validated_data
index = validated_data.pop("index", None)
super().update(instance, validated_data)
if update_index:
instance.playlist.insert(instance, index)
return instance
def get_unique_together_validators(self):
"""
We explicitely disable unique together validation here
because it collides with our internal logic
"""
return []
def get_track(self, o):
track = o._prefetched_track if hasattr(o, "_prefetched_track") else o.track
return TrackSerializer(track).data
class PlaylistSerializer(serializers.ModelSerializer):
tracks_count = serializers.SerializerMethodField(read_only=True)
duration = serializers.SerializerMethodField(read_only=True)
album_covers = serializers.SerializerMethodField(read_only=True)
user = UserBasicSerializer(read_only=True)
is_playable = serializers.SerializerMethodField()
actor = FullActorSerializer(read_only=True)
library = serializers.SerializerMethodField()
library_followed = serializers.SerializerMethodField()
privacy_level = serializers.ChoiceField(
choices=PRIVACY_LEVEL_CHOICES, required=False
)
class Meta:
model = models.Playlist
fields = (
"id",
"uuid",
"fid",
"name",
"user",
"actor",
"modification_date",
"creation_date",
"privacy_level",
"tracks_count",
"album_covers",
"duration",
"is_playable",
"actor",
"description",
"library",
"library_followed",
)
read_only_fields = ["id", "modification_date", "creation_date"]
def get_tracks_count(self, obj):
try:
return obj.tracks_count
except AttributeError:
# no annotation?
return obj.playlist_tracks.count()
def get_duration(self, obj):
read_only_fields = ["uuid", "fid", "modification_date", "creation_date"]
@extend_schema_field(OpenApiTypes.URI)
def get_library(self, obj):
if obj.library:
return obj.library.fid
else:
return None
@extend_schema_field(OpenApiTypes.BOOL)
def get_library_followed(self, obj):
if (
self.context.get("request", False)
and hasattr(self.context["request"], "user")
and hasattr(self.context["request"].user, "actor")
):
actor = self.context["request"].user.actor
lib_qs = obj.library.received_follows.filter(actor=actor)
if lib_qs.exists():
if lib_qs[0].approved is None:
return False
else:
return lib_qs[0].approved
else:
return None
return None
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_playable(self, obj):
return getattr(obj, "is_playable_by_actor", False)
def get_tracks_count(self, obj) -> int:
return getattr(obj, "tracks_count", obj.playlist_tracks.count())
def get_duration(self, obj) -> int:
try:
return obj.duration
except AttributeError:
# no annotation?
return 0
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_album_covers(self, obj):
try:
plts = obj.plts_for_cover
except AttributeError:
return []
excluded_artists = []
try:
user = self.context["request"].user
except (KeyError, AttributeError):
user = None
if user and user.is_authenticated:
excluded_artists = list(
user.content_filters.values_list("target_artist", flat=True)
)
covers = []
max_covers = 5
for plt in plts:
url = plt.track.album.cover.crop["200x200"].url
if [
ac.artist.pk for ac in plt.track.album.artist_credit.all()
] in excluded_artists:
continue
url = plt.track.album.attachment_cover.download_url_medium_square_crop
if url in covers:
continue
covers.append(url)
......@@ -126,3 +145,64 @@ class PlaylistAddManySerializer(serializers.Serializer):
tracks = serializers.PrimaryKeyRelatedField(
many=True, queryset=Track.objects.for_nested_serialization()
)
allow_duplicates = serializers.BooleanField(required=False)
class Meta:
fields = "allow_duplicates"
class XspfTrackSerializer(serializers.Serializer):
location = serializers.CharField(allow_blank=True, required=False)
title = serializers.CharField()
creator = serializers.CharField()
album = serializers.CharField(allow_blank=True, required=False)
duration = serializers.CharField(allow_blank=True, required=False)
def validate(self, data):
title = data["title"]
album = data.get("album", None)
acs_tuples = tasks.parse_credits(data["creator"], "", 0)
try:
artist_id = Artist.objects.get(name=acs_tuples[0][0])
except ObjectDoesNotExist:
raise ValidationError("Couldn't find artist in the database")
if album:
try:
album_id = Album.objects.get(title=album)
fw_track = Track.objects.get(
title=title, artist_credit__artist=artist_id, album=album_id
)
except ObjectDoesNotExist:
pass
try:
fw_track = Track.objects.get(title=title, artist_credit__artist=artist_id)
except ObjectDoesNotExist as e:
raise ValidationError(f"Couldn't find track in the database : {e!r}")
super().validate(data)
return fw_track
class XspfSerializer(serializers.Serializer):
title = serializers.CharField()
creator = serializers.CharField(allow_blank=True, required=False)
creation_date = serializers.DateTimeField(required=False)
version = serializers.IntegerField(required=False)
tracks = XspfTrackSerializer(many=True, required=False)
def create(self, validated_data):
pl = models.Playlist.objects.create(
name=validated_data["title"],
privacy_level="private",
actor=validated_data["request"].user.actor,
)
pl.insert_many(validated_data["tracks"])
return pl
def update(self, instance, validated_data):
instance.name = validated_data["title"]
instance.playlist_tracks.all().delete()
instance.insert_many(validated_data["tracks"])
instance.save()
return instance