From 4e7f1e63d2fb99665194a0dfc01780d6f3cc499e Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Sat, 19 Oct 2019 18:10:42 +0200
Subject: [PATCH] Denormalized audio permission logic in a separate table to
 enhance performance

---
 api/config/settings/common.py                 |   4 +-
 api/config/settings/local.py                  |  16 ++
 api/funkwhale_api/federation/factories.py     |   3 +
 api/funkwhale_api/federation/models.py        |  40 +++++
 .../commands/rebuild_music_permissions.py     |  59 +++++++
 .../migrations/0040_auto_20191021_1318.py     |  32 ++++
 .../migrations/0041_auto_20191021_1705.py     |  47 +++++
 .../0042_denormalize_audio_permissions.py.bak |  40 +++++
 api/funkwhale_api/music/models.py             | 143 ++++++++++++++-
 api/funkwhale_api/music/tasks.py              |   9 +-
 api/requirements/local.txt                    |   1 +
 api/setup.cfg                                 |   1 +
 api/tests/federation/test_models.py           |  81 +++++++++
 api/tests/music/test_models.py                | 167 ++++++++++++++++--
 api/tests/music/test_tasks.py                 |   8 +
 .../changelog.d/denormalization.enhancement   |   1 +
 changes/notes.rst                             |  17 ++
 17 files changed, 648 insertions(+), 21 deletions(-)
 create mode 100644 api/funkwhale_api/music/management/commands/rebuild_music_permissions.py
 create mode 100644 api/funkwhale_api/music/migrations/0040_auto_20191021_1318.py
 create mode 100644 api/funkwhale_api/music/migrations/0041_auto_20191021_1705.py
 create mode 100644 api/funkwhale_api/music/migrations/0042_denormalize_audio_permissions.py.bak
 create mode 100644 changes/changelog.d/denormalization.enhancement

diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 43a7a29f52..5d88260753 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -851,7 +851,9 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
 MUSIC_DIRECTORY_SERVE_PATH = env(
     "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
 )
-
+# When this is set to default=True, we need to reenable migration music/0042
+# to ensure data is populated correctly on existing pods
+MUSIC_USE_DENORMALIZATION = env.bool("MUSIC_USE_DENORMALIZATION", default=False)
 USERS_INVITATION_EXPIRATION_DAYS = env.int(
     "USERS_INVITATION_EXPIRATION_DAYS", default=14
 )
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index 632eb32015..24af04e5ab 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -41,6 +41,22 @@ DEBUG_TOOLBAR_CONFIG = {
     "SHOW_TOOLBAR_CALLBACK": lambda request: True,
     "JQUERY_URL": "/staticfiles/admin/js/vendor/jquery/jquery.js",
 }
+# DEBUG_TOOLBAR_PANELS = [
+#     'debug_toolbar.panels.versions.VersionsPanel',
+#     'debug_toolbar.panels.timer.TimerPanel',
+#     'debug_toolbar.panels.settings.SettingsPanel',
+#     'debug_toolbar.panels.headers.HeadersPanel',
+#     'debug_toolbar.panels.request.RequestPanel',
+#     'debug_toolbar.panels.sql.SQLPanel',
+#     'debug_toolbar.panels.staticfiles.StaticFilesPanel',
+#     'debug_toolbar.panels.templates.TemplatesPanel',
+#     'debug_toolbar.panels.cache.CachePanel',
+#     'debug_toolbar.panels.signals.SignalsPanel',
+#     'debug_toolbar.panels.logging.LoggingPanel',
+#     'debug_toolbar.panels.redirects.RedirectsPanel',
+#     'debug_toolbar.panels.profiling.ProfilingPanel',
+#     'debug_toolbar_line_profiler.panel.ProfilingPanel'
+# ]
 
 # django-extensions
 # ------------------------------------------------------------------------------
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 95d68779b9..4f12729fc3 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -165,6 +165,9 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     class Meta:
         model = "music.Library"
 
+    class Params:
+        local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
+
 
 @registry.register
 class LibraryScanFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index c734939b3f..fd52e17c86 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -9,6 +9,8 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
+from django.db.models.signals import post_save, pre_save, post_delete
+from django.dispatch import receiver
 from django.utils import timezone
 from django.urls import reverse
 
@@ -554,3 +556,41 @@ class LibraryTrack(models.Model):
 
     def get_metadata(self, key):
         return self.metadata.get(key)
+
+
+@receiver(pre_save, sender=LibraryFollow)
+def set_approved_updated(sender, instance, update_fields, **kwargs):
+    if not instance.pk or not instance.actor.is_local:
+        return
+    if update_fields is not None and "approved" not in update_fields:
+        return
+    db_value = instance.__class__.objects.filter(pk=instance.pk).values_list(
+        "approved", flat=True
+    )[0]
+    if db_value != instance.approved:
+        # Needed to update denormalized permissions
+        setattr(instance, "_approved_updated", True)
+
+
+@receiver(post_save, sender=LibraryFollow)
+def update_denormalization_follow_approved(sender, instance, created, **kwargs):
+    from funkwhale_api.music import models as music_models
+
+    updated = getattr(instance, "_approved_updated", False)
+
+    if (created or updated) and instance.actor.is_local:
+        music_models.TrackActor.create_entries(
+            instance.target,
+            actor_ids=[instance.actor.pk],
+            delete_existing=not instance.approved,
+        )
+
+
+@receiver(post_delete, sender=LibraryFollow)
+def update_denormalization_follow_deleted(sender, instance, **kwargs):
+    from funkwhale_api.music import models as music_models
+
+    if instance.actor.is_local:
+        music_models.TrackActor.objects.filter(
+            actor=instance.actor, upload__in=instance.target.uploads.all()
+        ).delete()
diff --git a/api/funkwhale_api/music/management/commands/rebuild_music_permissions.py b/api/funkwhale_api/music/management/commands/rebuild_music_permissions.py
new file mode 100644
index 0000000000..9a3e849d2a
--- /dev/null
+++ b/api/funkwhale_api/music/management/commands/rebuild_music_permissions.py
@@ -0,0 +1,59 @@
+from argparse import RawTextHelpFormatter
+
+from django.core.management.base import BaseCommand
+from django.core.management.base import CommandError
+
+from django.db import transaction
+from django.db.models import Q
+
+from funkwhale_api.music.models import TrackActor, Library
+from funkwhale_api.federation.models import Actor
+
+
+class Command(BaseCommand):
+    help = """
+    Rebuild audio permission table. You shouldn't have to do this by hand, but if you face
+    any weird things (tracks still shown when they shouldn't, or tracks not shown when they should),
+    this may help.
+
+    """
+
+    def create_parser(self, *args, **kwargs):
+        parser = super().create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "username",
+            nargs='*',
+            help="Rebuild only for given users",
+        )
+
+    @transaction.atomic
+    def handle(self, *args, **options):
+        actor_ids = []
+        if options['username']:
+            actors = Actor.objects.all().local(True)
+            actor_ids = list(actors.filter(preferred_username__in=options['username']).values_list('id', flat=True))
+            if len(actor_ids) < len(options['username']):
+                raise CommandError('Invalid username')
+            print('Emptying permission table for specified users…')
+            qs = TrackActor.objects.all().filter(Q(actor__pk__in=actor_ids) | Q(actor=None))
+            qs._raw_delete(qs.db)
+        else:
+            print('Emptying permission table…')
+            qs = TrackActor.objects.all()
+            qs._raw_delete(qs.db)
+        libraries = Library.objects.all()
+        objs = []
+        total_libraries = len(libraries)
+        for i, library in enumerate(libraries):
+            print('[{}/{}] Populating permission table for library {}'.format(i + 1, total_libraries, library.pk))
+            objs += TrackActor.get_objs(
+                library=library,
+                actor_ids=actor_ids,
+                upload_and_track_ids=[],
+            )
+        print('Commiting changes…')
+        TrackActor.objects.bulk_create(objs, batch_size=5000, ignore_conflicts=True)
diff --git a/api/funkwhale_api/music/migrations/0040_auto_20191021_1318.py b/api/funkwhale_api/music/migrations/0040_auto_20191021_1318.py
new file mode 100644
index 0000000000..eb5e6abc0d
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0040_auto_20191021_1318.py
@@ -0,0 +1,32 @@
+# Generated by Django 2.2.5 on 2019-10-21 13:18
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import funkwhale_api.music.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('music', '0039_auto_20190423_0820'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='TrackActor',
+            fields=[
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('internal', models.BooleanField(db_index=True, default=False)),
+                ('track', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='track_actor_items', to='music.Track')),
+                ('upload', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='track_actor_items', to='music.Upload')),
+                ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='track_actor_items', to='federation.Actor')),
+            ],
+            options={
+                'unique_together': {('track', 'actor', 'internal', 'upload')},
+            },
+        ),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0041_auto_20191021_1705.py b/api/funkwhale_api/music/migrations/0041_auto_20191021_1705.py
new file mode 100644
index 0000000000..cd98e77071
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0041_auto_20191021_1705.py
@@ -0,0 +1,47 @@
+# Generated by Django 2.2.5 on 2019-10-21 17:05
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import funkwhale_api.music.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0040_auto_20191021_1318'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='album',
+            name='release_date',
+            field=models.DateField(blank=True, db_index=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='upload',
+            name='from_activity',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='federation.Activity'),
+        ),
+        migrations.AlterField(
+            model_name='upload',
+            name='import_details',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
+        ),
+        migrations.AlterField(
+            model_name='upload',
+            name='import_metadata',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
+        ),
+        migrations.AlterField(
+            model_name='upload',
+            name='metadata',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
+        ),
+        migrations.AlterField(
+            model_name='uploadversion',
+            name='mimetype',
+            field=models.CharField(choices=[('video/ogg', 'ogg'), ('audio/ogg', 'ogg'), ('audio/opus', 'opus'), ('audio/mpeg', 'mp3'), ('audio/x-m4a', 'aac'), ('audio/x-m4a', 'm4a'), ('audio/x-flac', 'flac'), ('audio/flac', 'flac')], max_length=50),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0042_denormalize_audio_permissions.py.bak b/api/funkwhale_api/music/migrations/0042_denormalize_audio_permissions.py.bak
new file mode 100644
index 0000000000..625035bf71
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0042_denormalize_audio_permissions.py.bak
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+"""
+This migration is disabled until settings.MUSIC_USE_DENORMALIZATION is set to default=True
+"""
+from __future__ import unicode_literals
+
+from django.db import migrations
+from funkwhale_api.music.utils import guess_mimetype
+
+
+def denormalize(apps, schema_editor):
+    Upload = apps.get_model("music", "Upload")
+    if not Upload.objects.count():
+        print('Skipping…')
+
+    from funkwhale_api.music.models import TrackActor, Library
+    libraries = Library.objects.all()
+    objs = []
+    total_libraries = len(libraries)
+    for i, library in enumerate(libraries):
+        print('[{}/{}] Populating permission table for library {}'.format(i+1, total_libraries, library.pk))
+        objs += TrackActor.get_objs(
+            library=library,
+            actor_ids=[],
+            upload_and_track_ids=[],
+        )
+    print('Commiting changes…')
+    TrackActor.objects.bulk_create(objs, batch_size=5000, ignore_conflicts=True)
+
+
+
+def rewind(apps, schema_editor):
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("music", "0041_auto_20191021_1705")]
+
+    operations = [migrations.RunPython(denormalize, rewind)]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 49d22fa854..75126eda2b 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -14,7 +14,7 @@ from django.contrib.postgres.fields import JSONField
 from django.core.files.base import ContentFile
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models, transaction
-from django.db.models.signals import post_save
+from django.db.models.signals import post_save, pre_save
 from django.dispatch import receiver
 from django.urls import reverse
 from django.utils import timezone
@@ -185,8 +185,8 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
         return self.annotate(is_playable_by_actor=subquery)
 
     def playable_by(self, actor, include=True):
-        tracks = Track.objects.playable_by(actor, include)
-        matches = self.filter(tracks__in=tracks).values_list("pk")
+        tracks = Track.objects.playable_by(actor)
+        matches = self.filter(pk__in=tracks.values("artist_id")).values_list("pk")
         if include:
             return self.filter(pk__in=matches)
         else:
@@ -269,8 +269,8 @@ class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
         return self.annotate(is_playable_by_actor=subquery)
 
     def playable_by(self, actor, include=True):
-        tracks = Track.objects.playable_by(actor, include)
-        matches = self.filter(tracks__in=tracks).values_list("pk")
+        tracks = Track.objects.playable_by(actor)
+        matches = self.filter(pk__in=tracks.values("album_id")).values_list("pk")
         if include:
             return self.filter(pk__in=matches)
         else:
@@ -418,6 +418,7 @@ class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
         return self.select_related().select_related("album__artist", "artist")
 
     def annotate_playable_by_actor(self, actor):
+
         files = (
             Upload.objects.playable_by(actor)
             .filter(track=models.OuterRef("id"))
@@ -428,6 +429,15 @@ class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
         return self.annotate(is_playable_by_actor=subquery)
 
     def playable_by(self, actor, include=True):
+
+        if settings.MUSIC_USE_DENORMALIZATION:
+            if actor is not None:
+                query = models.Q(actor=None) | models.Q(actor=actor)
+            else:
+                query = models.Q(actor=None, internal=False)
+            if not include:
+                query = ~query
+            return self.filter(pk__in=TrackActor.objects.filter(query).values("track"))
         files = Upload.objects.playable_by(actor, include)
         matches = self.filter(uploads__in=files).values_list("pk")
         if include:
@@ -1147,11 +1157,134 @@ class LibraryScan(models.Model):
     modification_date = models.DateTimeField(null=True, blank=True)
 
 
+class TrackActor(models.Model):
+    """
+    Denormalization table to store all playable tracks for a given user
+    Empty user means the track is public or internal (cf internal flag too)
+    """
+
+    id = models.BigAutoField(primary_key=True)
+    actor = models.ForeignKey(
+        "federation.Actor",
+        on_delete=models.CASCADE,
+        related_name="track_actor_items",
+        blank=True,
+        null=True,
+    )
+    track = models.ForeignKey(
+        Track, on_delete=models.CASCADE, related_name="track_actor_items"
+    )
+    upload = models.ForeignKey(
+        Upload, on_delete=models.CASCADE, related_name="track_actor_items"
+    )
+    internal = models.BooleanField(default=False, db_index=True)
+
+    class Meta:
+        unique_together = ("track", "actor", "internal", "upload")
+
+    @classmethod
+    def get_objs(cls, library, actor_ids, upload_and_track_ids):
+        upload_and_track_ids = upload_and_track_ids or library.uploads.filter(
+            import_status="finished", track__isnull=False
+        ).values_list("id", "track")
+        objs = []
+        if library.privacy_level == "me":
+            follow_queryset = library.received_follows.filter(approved=True).exclude(
+                actor__user__isnull=True
+            )
+            if actor_ids:
+                follow_queryset = follow_queryset.filter(actor__pk__in=actor_ids)
+            final_actor_ids = list(follow_queryset.values_list("actor", flat=True))
+
+            owner = library.actor if library.actor.is_local else None
+            if owner and (not actor_ids or owner in final_actor_ids):
+                final_actor_ids.append(owner.pk)
+            for actor_id in final_actor_ids:
+                for upload_id, track_id in upload_and_track_ids:
+                    objs.append(
+                        cls(actor_id=actor_id, track_id=track_id, upload_id=upload_id)
+                    )
+
+        elif library.privacy_level == "instance":
+            for upload_id, track_id in upload_and_track_ids:
+                objs.append(
+                    cls(
+                        actor_id=None,
+                        track_id=track_id,
+                        upload_id=upload_id,
+                        internal=True,
+                    )
+                )
+        elif library.privacy_level == "everyone":
+            for upload_id, track_id in upload_and_track_ids:
+                objs.append(cls(actor_id=None, track_id=track_id, upload_id=upload_id))
+        return objs
+
+    @classmethod
+    def create_entries(
+        cls, library, delete_existing=True, actor_ids=None, upload_and_track_ids=None
+    ):
+        if not settings.MUSIC_USE_DENORMALIZATION:
+            # skip
+            return
+        if delete_existing:
+            to_delete = cls.objects.filter(upload__library=library)
+            if actor_ids:
+                to_delete = to_delete.filter(actor__pk__in=actor_ids)
+            # we don't use .delete() here because we don't want signals to fire
+            to_delete._raw_delete(to_delete.db)
+
+        objs = cls.get_objs(
+            library, actor_ids=actor_ids, upload_and_track_ids=upload_and_track_ids
+        )
+        return cls.objects.bulk_create(objs, ignore_conflicts=True, batch_size=5000)
+
+
 @receiver(post_save, sender=ImportJob)
 def update_batch_status(sender, instance, **kwargs):
     instance.batch.update_status()
 
 
+@receiver(post_save, sender=Upload)
+def update_denormalization_track_actor(sender, instance, created, **kwargs):
+    if (
+        created
+        and settings.MUSIC_USE_DENORMALIZATION
+        and instance.track_id
+        and instance.import_status == "finished"
+    ):
+        TrackActor.create_entries(
+            instance.library,
+            delete_existing=False,
+            upload_and_track_ids=[(instance.pk, instance.track_id)],
+        )
+
+
+@receiver(pre_save, sender=Library)
+def set_privacy_level_updated(sender, instance, update_fields, **kwargs):
+    if not instance.pk:
+        return
+    if update_fields is not None and "privacy_level" not in update_fields:
+        return
+    db_value = instance.__class__.objects.filter(pk=instance.pk).values_list(
+        "privacy_level", flat=True
+    )[0]
+    if db_value != instance.privacy_level:
+        # Needed to update denormalized permissions
+        setattr(instance, "_privacy_level_updated", True)
+
+
+@receiver(post_save, sender=Library)
+def update_denormalization_track_user_library_privacy_level(
+    sender, instance, created, **kwargs
+):
+    if created:
+        return
+    updated = getattr(instance, "_privacy_level_updated", False)
+    if updated:
+        TrackActor.create_entries(instance)
+
+
 @receiver(post_save, sender=ImportBatch)
 def update_request_status(sender, instance, created, **kwargs):
     update_fields = kwargs.get("update_fields", []) or []
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index ca32c808ea..20265b3206 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -170,7 +170,7 @@ def fail_import(upload, error_code, detail=None, **fields):
     ),
     "upload",
 )
-def process_upload(upload):
+def process_upload(upload, update_denormalization=True):
     import_metadata = upload.import_metadata or {}
     old_status = upload.import_status
     audio_file = upload.get_audio_file()
@@ -249,6 +249,13 @@ def process_upload(upload):
         ]
     )
 
+    if update_denormalization:
+        models.TrackActor.create_entries(
+            library=upload.library,
+            upload_and_track_ids=[(upload.pk, upload.track_id)],
+            delete_existing=False,
+        )
+
     # update album cover, if needed
     if not track.album.cover:
         update_album_cover(
diff --git a/api/requirements/local.txt b/api/requirements/local.txt
index dcedb43e75..d60e07e7b2 100644
--- a/api/requirements/local.txt
+++ b/api/requirements/local.txt
@@ -14,3 +14,4 @@ profiling
 
 asynctest==0.12.2
 aioresponses==0.6.0
+https://github.com/dmclain/django-debug-toolbar-line-profiler/archive/master.zip
diff --git a/api/setup.cfg b/api/setup.cfg
index 3f7d2f7f07..f50bd54739 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -26,3 +26,4 @@ env =
     FORCE_HTTPS_URLS=False
     FUNKWHALE_SPA_HTML_ROOT=http://noop/
     PROXY_MEDIA=true
+    MUSIC_USE_DENORMALIZATION=true
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index a7460b01cb..667caaa787 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -2,6 +2,7 @@ import pytest
 from django import db
 
 from funkwhale_api.federation import models
+from funkwhale_api.music import models as music_models
 
 
 def test_cannot_duplicate_actor(factories):
@@ -174,3 +175,83 @@ def test_can_create_fetch_for_object(factories):
     assert fetch.status == "pending"
     assert fetch.detail == {}
     assert fetch.object == track
+
+
+@pytest.mark.parametrize(
+    "initial_approved, updated_approved, initial_playable_tracks, updated_playable_tracks",
+    [
+        (
+            True,
+            False,
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+            {"owner": [0], "follower": [], "local_actor": [], None: []},
+        ),
+        (
+            False,
+            True,
+            {"owner": [0], "follower": [], "local_actor": [], None: []},
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+        ),
+    ],
+)
+def test_update_library_follow_approved_create_entries(
+    initial_approved,
+    updated_approved,
+    initial_playable_tracks,
+    updated_playable_tracks,
+    factories,
+):
+    actors = {
+        "owner": factories["federation.Actor"](local=True),
+        "follower": factories["federation.Actor"](local=True),
+        "local_actor": factories["federation.Actor"](local=True),
+        None: None,
+    }
+    library = factories["music.Library"](actor=actors["owner"], privacy_level="me")
+
+    tracks = [
+        factories["music.Upload"](playable=True, library=library).track,
+        factories["music.Upload"](library=library, import_status="pending").track,
+    ]
+
+    follow = factories["federation.LibraryFollow"](
+        target=library, actor=actors["follower"], approved=initial_approved
+    )
+
+    for actor_name, expected in initial_playable_tracks.items():
+        actor = actors[actor_name]
+        expected_tracks = [tracks[i] for i in expected]
+        assert list(music_models.Track.objects.playable_by(actor)) == expected_tracks
+
+    follow.approved = updated_approved
+    follow.save()
+
+    for actor_name, expected in updated_playable_tracks.items():
+        actor = actors[actor_name]
+        expected_tracks = [tracks[i] for i in expected]
+        assert list(music_models.Track.objects.playable_by(actor)) == expected_tracks
+
+
+def test_update_library_follow_delete_delete_denormalization_entries(factories,):
+    updated_playable_tracks = {"owner": [0], "follower": []}
+    actors = {
+        "owner": factories["federation.Actor"](local=True),
+        "follower": factories["federation.Actor"](local=True),
+    }
+    library = factories["music.Library"](actor=actors["owner"], privacy_level="me")
+
+    tracks = [
+        factories["music.Upload"](playable=True, library=library).track,
+        factories["music.Upload"](library=library, import_status="pending").track,
+    ]
+
+    follow = factories["federation.LibraryFollow"](
+        target=library, actor=actors["follower"], approved=True
+    )
+
+    follow.delete()
+
+    for actor_name, expected in updated_playable_tracks.items():
+        actor = actors[actor_name]
+        expected_tracks = [tracks[i] for i in expected]
+        assert list(music_models.Track.objects.playable_by(actor)) == expected_tracks
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index 344273673d..a5090e89ac 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -212,7 +212,7 @@ def test_library(factories):
 )
 def test_playable_by_correct_status(status, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level="everyone", import_status=status
+        library__privacy_level="everyone", import_status=status, library__local=True
     )
     queryset = upload.library.uploads.playable_by(None)
     match = upload in list(queryset)
@@ -224,7 +224,9 @@ def test_playable_by_correct_status(status, expected, factories):
 )
 def test_playable_by_correct_actor(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     queryset = upload.library.uploads.playable_by(upload.library.actor)
     match = upload in list(queryset)
@@ -236,7 +238,9 @@ def test_playable_by_correct_actor(privacy_level, expected, factories):
 )
 def test_playable_by_instance_actor(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
     queryset = upload.library.uploads.playable_by(instance_actor)
@@ -249,7 +253,9 @@ def test_playable_by_instance_actor(privacy_level, expected, factories):
 )
 def test_playable_by_anonymous(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     queryset = upload.library.uploads.playable_by(None)
     match = upload in list(queryset)
@@ -259,7 +265,7 @@ def test_playable_by_anonymous(privacy_level, expected, factories):
 @pytest.mark.parametrize("approved", [True, False])
 def test_playable_by_follower(approved, factories):
     upload = factories["music.Upload"](
-        library__privacy_level="me", import_status="finished"
+        library__privacy_level="me", import_status="finished", library__local=True
     )
     actor = factories["federation.Actor"](local=True)
     factories["federation.LibraryFollow"](
@@ -275,7 +281,7 @@ def test_playable_by_follower(approved, factories):
     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
 )
 def test_track_playable_by_correct_actor(privacy_level, expected, factories):
-    upload = factories["music.Upload"](import_status="finished")
+    upload = factories["music.Upload"](import_status="finished", library__local=True)
     queryset = models.Track.objects.playable_by(
         upload.library.actor
     ).annotate_playable_by_actor(upload.library.actor)
@@ -290,7 +296,9 @@ def test_track_playable_by_correct_actor(privacy_level, expected, factories):
 )
 def test_track_playable_by_instance_actor(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
     queryset = models.Track.objects.playable_by(
@@ -307,7 +315,9 @@ def test_track_playable_by_instance_actor(privacy_level, expected, factories):
 )
 def test_track_playable_by_anonymous(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     queryset = models.Track.objects.playable_by(None).annotate_playable_by_actor(None)
     match = upload.track in list(queryset)
@@ -320,7 +330,7 @@ def test_track_playable_by_anonymous(privacy_level, expected, factories):
     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
 )
 def test_album_playable_by_correct_actor(privacy_level, expected, factories):
-    upload = factories["music.Upload"](import_status="finished")
+    upload = factories["music.Upload"](import_status="finished", library__local=True)
 
     queryset = models.Album.objects.playable_by(
         upload.library.actor
@@ -336,7 +346,9 @@ def test_album_playable_by_correct_actor(privacy_level, expected, factories):
 )
 def test_album_playable_by_instance_actor(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
     queryset = models.Album.objects.playable_by(
@@ -353,7 +365,9 @@ def test_album_playable_by_instance_actor(privacy_level, expected, factories):
 )
 def test_album_playable_by_anonymous(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     queryset = models.Album.objects.playable_by(None).annotate_playable_by_actor(None)
     match = upload.track.album in list(queryset)
@@ -366,7 +380,11 @@ def test_album_playable_by_anonymous(privacy_level, expected, factories):
     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
 )
 def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
-    upload = factories["music.Upload"](import_status="finished")
+    upload = factories["music.Upload"](
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
+    )
 
     queryset = models.Artist.objects.playable_by(
         upload.library.actor
@@ -382,7 +400,9 @@ def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
 )
 def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
     queryset = models.Artist.objects.playable_by(
@@ -399,7 +419,9 @@ def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
 )
 def test_artist_playable_by_anonymous(privacy_level, expected, factories):
     upload = factories["music.Upload"](
-        library__privacy_level=privacy_level, import_status="finished"
+        library__privacy_level=privacy_level,
+        import_status="finished",
+        library__local=True,
     )
     queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None)
     match = upload.track.artist in list(queryset)
@@ -554,3 +576,120 @@ def test_api_model_mixin_domain_name():
     obj = models.Track(fid="https://test.domain:543/something")
 
     assert obj.domain_name == "test.domain"
+
+
+@pytest.mark.parametrize(
+    "initial, updated, expected",
+    [
+        ({"name": "hello"}, {"name": "world"}, False),
+        ({"privacy_level": "internal"}, {"name": "world"}, False),
+        ({"privacy_level": "internal"}, {"privacy_level": "me"}, True),
+        ({"privacy_level": "internal"}, {"privacy_level": "internal"}, False),
+    ],
+)
+def test_saving_library_sets_privacy_level_updated_flag(
+    initial, updated, expected, factories
+):
+    library = factories["music.Library"](**initial)
+    for key, value in updated.items():
+        setattr(library, key, value)
+
+    library.save()
+
+    assert getattr(library, "_privacy_level_updated", False) is expected
+
+
+@pytest.mark.parametrize("value, expected", [(True, True), (False, False)])
+def test_saving_library_with_privacy_level_updated_flag(
+    value, expected, factories, mocker
+):
+    library = factories["music.Library"]()
+    create_entries = mocker.patch.object(models.TrackActor, "create_entries")
+    setattr(library, "_privacy_level_updated", value)
+    library.save()
+
+    called = create_entries.call_count > 0
+    assert called is expected
+    if expected:
+        create_entries.assert_called_once_with(library)
+
+
+@pytest.mark.parametrize(
+    "initial_privacy_level, updated_privacy_level, initial_playable_tracks, updated_playable_tracks",
+    [
+        (
+            "me",
+            "everyone",
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+            {"owner": [0], "follower": [0], "local_actor": [0], None: [0]},
+        ),
+        (
+            "me",
+            "instance",
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+            {"owner": [0], "follower": [0], "local_actor": [0], None: []},
+        ),
+        (
+            "instance",
+            "me",
+            {"owner": [0], "follower": [0], "local_actor": [0], None: []},
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+        ),
+        (
+            "instance",
+            "everyone",
+            {"owner": [0], "follower": [0], "local_actor": [0], None: []},
+            {"owner": [0], "follower": [0], "local_actor": [0], None: [0]},
+        ),
+        (
+            "everyone",
+            "me",
+            {"owner": [0], "follower": [0], "local_actor": [0], None: [0]},
+            {"owner": [0], "follower": [0], "local_actor": [], None: []},
+        ),
+        (
+            "everyone",
+            "instance",
+            {"owner": [0], "follower": [0], "local_actor": [0], None: [0]},
+            {"owner": [0], "follower": [0], "local_actor": [0], None: []},
+        ),
+    ],
+)
+def test_update_library_privacy_level_create_entries(
+    initial_privacy_level,
+    updated_privacy_level,
+    initial_playable_tracks,
+    updated_playable_tracks,
+    factories,
+):
+    actors = {
+        "owner": factories["federation.Actor"](local=True),
+        "follower": factories["federation.Actor"](local=True),
+        "local_actor": factories["federation.Actor"](local=True),
+        None: None,
+    }
+    library = factories["music.Library"](
+        actor=actors["owner"], privacy_level=initial_privacy_level
+    )
+    factories["federation.LibraryFollow"](
+        target=library, actor=actors["follower"], approved=True
+    )
+
+    tracks = [
+        factories["music.Upload"](playable=True, library=library).track,
+        factories["music.Upload"](library=library, import_status="pending").track,
+    ]
+
+    for actor_name, expected in initial_playable_tracks.items():
+        actor = actors[actor_name]
+        expected_tracks = [tracks[i] for i in expected]
+        assert list(models.Track.objects.playable_by(actor)) == expected_tracks
+
+    library.privacy_level = updated_privacy_level
+
+    models.TrackActor.create_entries(library)
+
+    for actor_name, expected in updated_playable_tracks.items():
+        actor = actors[actor_name]
+        expected_tracks = [tracks[i] for i in expected]
+        assert list(models.Track.objects.playable_by(actor)) == expected_tracks
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index 8eae4a3bee..fbbb0197ae 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -316,6 +316,9 @@ def test_upload_import(now, factories, temp_signal, mocker):
     upload = factories["music.Upload"](
         track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}}
     )
+    create_entries = mocker.patch(
+        "funkwhale_api.music.models.TrackActor.create_entries"
+    )
 
     with temp_signal(signals.upload_import_status_updated) as handler:
         tasks.process_upload(upload_id=upload.pk)
@@ -343,6 +346,11 @@ def test_upload_import(now, factories, temp_signal, mocker):
     outbox.assert_called_once_with(
         {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload}
     )
+    create_entries.assert_called_once_with(
+        library=upload.library,
+        delete_existing=False,
+        upload_and_track_ids=[(upload.pk, upload.track_id)],
+    )
 
 
 def test_upload_import_get_audio_data(factories, mocker):
diff --git a/changes/changelog.d/denormalization.enhancement b/changes/changelog.d/denormalization.enhancement
new file mode 100644
index 0000000000..57b750a4fd
--- /dev/null
+++ b/changes/changelog.d/denormalization.enhancement
@@ -0,0 +1 @@
+Denormalized audio permission logic in a separate table to enhance performance
diff --git a/changes/notes.rst b/changes/notes.rst
index 96ac3d7651..6a37478e29 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -5,3 +5,20 @@ Next release notes
 
     Those release notes refer to the current development branch and are reset
     after each release.
+
+Denormalized audio permission logic in a separate table to enhance performance
+------------------------------------------------------------------------------
+
+With this release, we're introducing a performance enhancement that should drastically reduce the load on the database and API
+servers (cf https://dev.funkwhale.audio/funkwhale/funkwhale/merge_requests/939).
+
+Under the hood, we now maintain a separate table to link users to the tracks they are allowed to see. This change is **disabled**
+by default, but will be enabled by default starting in Funkwhale 0.21.
+
+If you want to try it now, add
+``MUSIC_USE_DENORMALIZATION=True`` to your ``.env`` file, restart Funkwhale, and run the following command::
+
+    python manage.py rebuild_music_permissions
+
+This shouldn't cause any regression, but we'd appreciate if you could test this before the 0.21 release and report any unusual
+behaviour regarding tracks, albums and artists visibility.
-- 
GitLab