diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index cf9b11a0a..58e201d2e 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -177,6 +177,7 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): size = None duration = None mimetype = "audio/ogg" + quality = 1 class Meta: model = "music.Upload" diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 94fa15e14..cc83d7782 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -115,6 +115,11 @@ class ArtistFilter( ) ) + has_mbid = filters.BooleanFilter( + field_name="_", + method="filter_has_mbid", + ) + class Meta: model = models.Artist fields = { @@ -132,6 +137,9 @@ class ArtistFilter( def filter_has_albums(self, queryset, name, value): return queryset.filter(albums__isnull=not value) + def filter_has_mbid(self, queryset, name, value): + return queryset.filter(mbid__isnull=(not value)) + class TrackFilter( RelatedFilterSet, @@ -171,6 +179,21 @@ class TrackFilter( ("tag_matches", "related"), ) ) + format = filters.CharFilter( + field_name="_", + method="filter_format", + ) + + has_mbid = filters.BooleanFilter( + field_name="_", + method="filter_has_mbid", + ) + + quality_choices = [(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")] + quality = filters.ChoiceFilter( + choices=quality_choices, + method="filter_quality", + ) class Meta: model = models.Track @@ -193,6 +216,23 @@ class TrackFilter( def filter_artist(self, queryset, name, value): return queryset.filter(Q(artist=value) | Q(album__artist=value)) + def filter_format(self, queryset, name, value): + mimetypes = [utils.get_type_from_ext(e) for e in value.split(",")] + return queryset.filter(uploads__mimetype__in=mimetypes) + + def filter_has_mbid(self, queryset, name, value): + return queryset.filter(mbid__isnull=(not value)) + + def filter_quality(self, queryset, name, value): + if value == "low": + return queryset.filter(upload__quality__gte=0) + if value == "medium": + return queryset.filter(upload__quality__gte=1) + if value == "high": + return queryset.filter(upload__quality__gte=2) + if value == "very-high": + return queryset.filter(upload__quality=3) + class UploadFilter(audio_filters.IncludeChannelsFilterSet): library = filters.CharFilter("library__uuid") @@ -270,6 +310,25 @@ class AlbumFilter( ("tag_matches", "related"), ) ) + has_tags = filters.BooleanFilter( + field_name="_", + method="filter_has_tags", + ) + + has_mbid = filters.BooleanFilter( + field_name="_", + method="filter_has_mbid", + ) + + has_cover = filters.BooleanFilter( + field_name="_", + method="filter_has_cover", + ) + + has_release_date = filters.BooleanFilter( + field_name="_", + method="filter_has_release_date", + ) class Meta: model = models.Album @@ -283,6 +342,18 @@ class AlbumFilter( actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) + def filter_has_tags(self, queryset, name, value): + return queryset.filter(tagged_items__isnull=(not value)) + + def filter_has_mbid(self, queryset, name, value): + return queryset.filter(mbid__isnull=(not value)) + + def filter_has_cover(self, queryset, name, value): + return queryset.filter(attachment_cover__isnull=(not value)) + + def filter_has_release_date(self, queryset, name, value): + return queryset.filter(release_date__isnull=(not value)) + class LibraryFilter(filters.FilterSet): q = fields.SearchFilter( diff --git a/api/funkwhale_api/music/migrations/0058_upload_quality.py b/api/funkwhale_api/music/migrations/0058_upload_quality.py new file mode 100644 index 000000000..efde3cbcc --- /dev/null +++ b/api/funkwhale_api/music/migrations/0058_upload_quality.py @@ -0,0 +1,100 @@ +# Generated by Django 3.2.23 on 2024-01-30 11:58 +import itertools + + +from django.db import migrations, models +from django.db.models import Q +from funkwhale_api.music import utils + + +def set_quality_upload(apps, schema_editor): + Upload = apps.get_model("music", "Upload") + extension_to_mimetypes = utils.get_extension_to_mimetype_dict() + + # Low quality + mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=192) + + OpusAACOGG_query = Q( + mimetype__in=list( + itertools.chain( + extension_to_mimetypes["opus"], + extension_to_mimetypes["ogg"], + extension_to_mimetypes["aac"], + ) + ) + ) & Q(bitrate__lte=96) + + low = Upload.objects.filter((mp3_query) | (OpusAACOGG_query)) + low.update(quality=0) + + # medium + mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=256) + ogg_query = Q(mimetype__in=extension_to_mimetypes["ogg"]) & Q(bitrate__lte=192) + + aacopus_query = Q( + mimetype__in=list( + itertools.chain( + extension_to_mimetypes["aac"], extension_to_mimetypes["opus"] + ) + ) + ) & Q(bitrate__lte=128) + + medium = Upload.objects.filter((mp3_query) | (ogg_query) | (aacopus_query)).exclude( + pk__in=low.values_list("pk", flat=True) + ) + medium.update(quality=1) + + # high + mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=320) + ogg_query = Q(mimetype__in=extension_to_mimetypes["ogg"]) & Q(bitrate__lte=256) + aac_query = Q(mimetype__in=extension_to_mimetypes["aac"]) & Q(bitrate__lte=288) + opus_query = Q(mimetype__in=extension_to_mimetypes["opus"]) & Q(bitrate__lte=160) + + high = ( + Upload.objects.filter((mp3_query) | (ogg_query) | (aac_query) | (opus_query)) + .exclude(pk__in=low.values_list("pk", flat=True)) + .exclude(pk__in=medium.values_list("pk", flat=True)) + ) + high.update(quality=2) + + # veryhigh + opus_query = Q(mimetype__in=extension_to_mimetypes["opus"]) & Q(bitrate__gte=510) + + flacaifaiff_query = Q( + mimetype__in=list( + itertools.chain( + extension_to_mimetypes["flac"], + extension_to_mimetypes["aif"], + extension_to_mimetypes["aiff"], + ) + ) + ) + Upload.objects.filter((opus_query) | (flacaifaiff_query)).exclude( + pk__in=low.values_list("pk", flat=True) + ).exclude(pk__in=medium.values_list("pk", flat=True)).exclude( + pk__in=high.values_list("pk", flat=True) + ).update( + quality=3 + ) + + +def skip(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("music", "0057_auto_20221118_2108"), + ] + + operations = [ + migrations.AddField( + model_name="upload", + name="quality", + field=models.IntegerField( + choices=[(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")], + default=1, + ), + ), + migrations.RunPython(set_quality_upload, skip), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fca044a16..cc46cdb61 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,4 +1,5 @@ import datetime +import itertools import logging import os import tempfile @@ -710,6 +711,9 @@ def get_import_reference(): return str(uuid.uuid4()) +quality_choices = [(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")] + + class Upload(models.Model): fid = models.URLField(unique=True, max_length=500, null=True, blank=True) uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) @@ -768,6 +772,7 @@ class Upload(models.Model): # stores checksums such as `sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` checksum = models.CharField(max_length=100, db_index=True, null=True, blank=True) + quality = models.IntegerField(choices=quality_choices, default=1) objects = UploadQuerySet.as_manager() @property @@ -871,6 +876,48 @@ class Upload(models.Model): audio = pydub.AudioSegment.from_file(input) return audio + def get_quality(self): + extension_to_mimetypes = utils.get_extension_to_mimetype_dict() + + if not self.bitrate and self.mimetype not in list( + itertools.chain( + extension_to_mimetypes["aiff"], + extension_to_mimetypes["aif"], + extension_to_mimetypes["flac"], + ) + ): + return 1 + + bitrate_limits = { + "mp3": {192: 0, 256: 1, 320: 2}, + "ogg": {96: 0, 192: 1, 256: 2}, + "aac": {96: 0, 128: 1, 288: 2}, + "m4a": {96: 0, 128: 1, 288: 2}, + "opus": { + 96: 0, + 128: 1, + 160: 2, + }, + } + + for ext in bitrate_limits: + if self.mimetype in extension_to_mimetypes[ext]: + for limit, quality in sorted(bitrate_limits[ext].items()): + if int(self.bitrate) <= limit: + return quality + + # opus higher tham 160 + return 3 + + if self.mimetype in list( + itertools.chain( + extension_to_mimetypes["aiff"], + extension_to_mimetypes["aif"], + extension_to_mimetypes["flac"], + ) + ): + return 3 + def save(self, **kwargs): if not self.mimetype: if self.audio_file: @@ -890,6 +937,7 @@ class Upload(models.Model): if not self.pk and not self.fid and self.library.actor.get_user(): self.fid = self.get_federation_id() + self.quality = self.get_quality() return super().save(**kwargs) def get_metadata(self): diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index a81e2cff0..df0f98728 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -70,6 +70,17 @@ MIMETYPE_TO_EXTENSION = {mt: ext for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} SUPPORTED_EXTENSIONS = list(sorted({ext for ext, _ in AUDIO_EXTENSIONS_AND_MIMETYPE})) +def get_extension_to_mimetype_dict(): + extension_dict = {} + + for ext, mimetype in AUDIO_EXTENSIONS_AND_MIMETYPE: + if ext not in extension_dict: + extension_dict[ext] = [] + extension_dict[ext].append(mimetype) + + return extension_dict + + def get_ext_from_type(mimetype): return MIMETYPE_TO_EXTENSION.get(mimetype) diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py index 54fc60072..9eb50dd71 100644 --- a/api/tests/music/test_filters.py +++ b/api/tests/music/test_filters.py @@ -1,7 +1,11 @@ +import os + import pytest from funkwhale_api.music import filters, models +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_artist_filter_ordering(factories, mocker): # Lista de prueba @@ -263,3 +267,124 @@ def test_filter_tag_related( queryset=obj.__class__.objects.all(), ) assert filterset.qs == matches + + +@pytest.mark.parametrize( + "extension, mimetype", [("ogg", "audio/ogg"), ("mp3", "audio/mpeg")] +) +def test_track_filter_format(extension, mimetype, factories, mocker, anonymous_user): + track_expected = factories["music.Track"]() + name = ".".join(["test", extension]) + path = os.path.join(DATA_DIR, name) + factories["music.Upload"]( + audio_file__from_path=path, track=track_expected, mimetype=mimetype + ) + + track_unexpected = factories["music.Track"]() + path_wrong_ext = os.path.join(DATA_DIR, "test.m4a") + factories["music.Upload"]( + audio_file__from_path=path_wrong_ext, + track=track_unexpected, + mimetype="audio/x-m4a", + ) + + qs = models.Track.objects.all() + filterset = filters.TrackFilter( + {"format": "ogg,mp3"}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + + assert filterset.qs[0] == track_expected + + +def test_album_filter_has_tags(factories, anonymous_user, mocker): + album_expected = factories["music.Album"]() + factories["music.Album"]() + + factories["tags.TaggedItem"](content_object=album_expected) + + qs = models.Album.objects.all() + filterset = filters.AlbumFilter( + {"has_tags": True}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + + assert filterset.qs[0] == album_expected + + +@pytest.mark.parametrize("fwobj", ["Album", "Track", "Artist"]) +def test_filter_has_mbid(fwobj, factories, anonymous_user, mocker): + obj_expected = factories[f"music.{fwobj}"]( + mbid="e9b9d574-537d-4d2d-a4c7-6f6c91eaf4e0" + ) + + factories[f"music.{fwobj}"](mbid=None) + model_class = getattr(models, fwobj) + qs = model_class.objects.all() + + filter_class = getattr(filters, f"{fwobj}Filter") + filterset = filter_class( + data={"has_mbid": True}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + + assert filterset.qs[0] == obj_expected + + +@pytest.mark.parametrize( + "mimetype, bitrate, quality", + [ + ("audio/mpeg", "20", "low"), + ("audio/ogg", "180", "medium"), + ("audio/x-m4a", "280", "high"), + ("audio/opus", "130", "high"), + ("audio/opus", "513", "very-high"), + ("audio/aiff", "1312", "very-high"), + ("audio/aiff", "1312", "low"), + ("audio/ogg", "180", "low"), + ], +) +def test_track_quality_filter( + factories, quality, mimetype, bitrate, mocker, anonymous_user +): + track = factories["music.Track"]() + factories["music.Upload"](track=track, mimetype=mimetype, bitrate=bitrate) + factories["music.Track"]() + + qs = models.Track.objects.all() + filterset = filters.TrackFilter( + {"quality": quality}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + assert track in filterset.qs + + +def test_album_has_cover(factories, mocker, anonymous_user): + attachment_cover = factories["common.Attachment"]() + album = factories["music.Album"](attachment_cover=attachment_cover) + factories["music.Album"].create_batch(5) + qs = models.Album.objects.all() + filterset = filters.AlbumFilter( + {"has_cover": True}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + + assert filterset.qs[0] == album + + +def test_album_has_release_date(factories, mocker, anonymous_user): + album = factories["music.Album"]() + factories["music.Album"](release_date=None) + qs = models.Album.objects.all() + filterset = filters.AlbumFilter( + {"has_release_date": True}, + request=mocker.Mock(user=anonymous_user), + queryset=qs, + ) + + assert filterset.qs[0] == album diff --git a/api/tests/music/test_migrations.py b/api/tests/music/test_migrations.py new file mode 100644 index 000000000..907f58678 --- /dev/null +++ b/api/tests/music/test_migrations.py @@ -0,0 +1,32 @@ +# this test is commented since it's very slow, but it can be useful for future development +# def test_pytest_plugin_initial(migrator): +# mapping_list = [ +# ("audio/mpeg", "20", "low", 0, 1), +# ("audio/ogg", "180", "medium", 1, 2), +# ("audio/x-m4a", "280", "high", 2, 3), +# ("audio/opus", "130", "high", 2, 4), +# ("audio/opus", "513", "very-high", 3, 5), +# ("audio/aiff", "1312", "very-high", 3, 6), +# ("audio/mpeg", "320", "high", 2, 8), +# ("audio/mpeg", "200", "medium", 1, 9), +# ("audio/aiff", "1", "very-high", 3, 10), +# ("audio/flac", "1", "very-high", 3, 11), +# ] + +# a, f, t = ("music", "0057_auto_20221118_2108", "0058_upload_quality") + +# migrator.migrate([(a, f)]) +# old_apps = migrator.loader.project_state([(a, f)]).apps +# Upload = old_apps.get_model(a, "Upload") +# for upload in mapping_list: +# Upload.objects.create(pk=upload[4], mimetype=upload[0], bitrate=upload[1]) + +# migrator.loader.build_graph() +# migrator.migrate([(a, t)]) +# new_apps = migrator.loader.project_state([(a, t)]).apps + +# upload_manager = new_apps.get_model(a, "Upload") + +# for upload in mapping_list: +# upload_obj = upload_manager.objects.get(pk=upload[4]) +# assert upload_obj.quality == upload[3] diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 28af9182f..94bbd7501 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -702,3 +702,19 @@ def test_update_library_privacy_level_create_entries( actor = actors[actor_name] expected_tracks = [tracks[i] for i in expected] assert list(models.Track.objects.playable_by(actor)) == expected_tracks + + +@pytest.mark.parametrize( + "mimetype, bitrate, quality", + [ + ("audio/mpeg", "20", 0), + ("audio/ogg", "180", 1), + ("audio/x-m4a", "280", 2), + ("audio/opus", "130", 2), + ("audio/opus", "161", 3), + ("audio/flac", "1312", 3), + ], +) +def test_save_upload_quality(factories, mimetype, bitrate, quality): + upload = factories["music.Upload"](mimetype=mimetype, bitrate=bitrate) + assert upload.quality == quality diff --git a/changes/changelog.d/2275.feature b/changes/changelog.d/2275.feature new file mode 100644 index 000000000..91c3c38da --- /dev/null +++ b/changes/changelog.d/2275.feature @@ -0,0 +1 @@ +Quality filters backend (#2275)