kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Porównaj commity
12 Commity
ef47cc79af
...
0281655176
Autor | SHA1 | Data |
---|---|---|
petitminion | 0281655176 | |
Petitminion | 4bef27552f | |
Ciarán Ainsworth | ec368e0cd3 | |
Ciarán Ainsworth | a2579bdc60 | |
Ciarán Ainsworth | e1e0045a23 | |
Ciarán Ainsworth | 85c2be6a5b | |
Ciarán Ainsworth | 35de9bd48e | |
Petitminion | c086770f97 | |
Petitminion | 857196cb26 | |
Petitminion | a0acdd18a1 | |
Petitminion | d94c479799 | |
Petitminion | 2532dbb4a5 |
2
.env.dev
2
.env.dev
|
@ -18,6 +18,6 @@ MEDIA_ROOT=/data/media
|
|||
# FORCE_HTTPS_URLS=True
|
||||
|
||||
# Customize to your needs
|
||||
POSTGRES_VERSION=11
|
||||
POSTGRES_VERSION=15
|
||||
DEBUG=true
|
||||
TYPESENSE_API_KEY="apikey"
|
||||
|
|
|
@ -177,6 +177,7 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
size = None
|
||||
duration = None
|
||||
mimetype = "audio/ogg"
|
||||
quality = 1
|
||||
|
||||
class Meta:
|
||||
model = "music.Upload"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import django_filters
|
||||
from django.db.models import Q
|
||||
from django_filters import rest_framework as filters
|
||||
from django_filters import widgets
|
||||
|
||||
from funkwhale_api.audio import filters as audio_filters
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
|
@ -115,6 +116,11 @@ class ArtistFilter(
|
|||
)
|
||||
)
|
||||
|
||||
has_mbid = filters.BooleanFilter(
|
||||
field_name="_",
|
||||
method="filter_has_mbid",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = {
|
||||
|
@ -132,6 +138,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 +180,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 +217,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 +311,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 +343,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(
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
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 +266,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
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# this test is commented since it's very slow, but it can be usefull for future developement
|
||||
# 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]
|
|
@ -6,7 +6,7 @@ from django.utils import timezone
|
|||
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import importers, models, tasks
|
||||
from funkwhale_api.music import importers, models, tasks, migrations
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Quality filters backend (#2275)
|
|
@ -0,0 +1 @@
|
|||
Add genre tags spec.
|
2
dev.yml
2
dev.yml
|
@ -25,7 +25,7 @@ services:
|
|||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: postgres:${POSTGRES_VERSION-11}-alpine
|
||||
image: postgres:${POSTGRES_VERSION-15}-alpine
|
||||
environment:
|
||||
- "POSTGRES_HOST_AUTH_METHOD=trust"
|
||||
command: postgres ${POSTGRES_ARGS-}
|
||||
|
|
|
@ -109,6 +109,7 @@ specs/multi-artist/index
|
|||
specs/user-follow/index
|
||||
specs/user-deletion/index
|
||||
specs/upload-process/index
|
||||
specs/genre-tags/index
|
||||
|
||||
```
|
||||
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
# Genre tags
|
||||
|
||||
## The issue
|
||||
|
||||
Funkwhale offers users a facility to assign genre tags to items such as tracks, albums, and artists. The `tags_tag` table is populated automatically when new tags are found in uploaded content, and users can also enter custom tags. By default, the table is empty. This means that a user on a new pod won't see any results when attempting to tag items in the frontend.
|
||||
|
||||
## The solution
|
||||
|
||||
To provide the best experience for new Funkwhale users, we should pre-populate this table with [genre tags from Musicbrainz](https://musicbrainz.org/genres). Doing this enables users to easily search for and select the tags they want to assign to their content without needing to create custom tags or upload tagged content.
|
||||
|
||||
Having these tags easily available also facilitates better tagging within Funkwhale in future, reducing the reliance on external tools such as Picard.
|
||||
|
||||
## Feature behavior
|
||||
|
||||
### Backend behavior
|
||||
|
||||
The `tags_tag` table contains the following fields:
|
||||
|
||||
| Field | Data type | Description | Relations | Constraints |
|
||||
| ---------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------- |
|
||||
| `id` | Integer | The randomly generated table ID | `tags_taggeditem.tag_id` foreign key | None |
|
||||
| `musicbrainz_id` | UUID | The Musicbrainz genre tag `id`. Used to identify the tag in Musicbrainz fetches | None | None |
|
||||
| `name` | String | The name of the tag. Assigned by Funkwhale during creation for use in URLs. Uses Pascal casing for consistency | None | Must be unique |
|
||||
| `display_name` | String | The name of the tag as the user entered it or as it was originally written by Musicbrainz. Lowercase, normalizes spaces | None | None |
|
||||
| `creation_date` | Timestamp with time zone | The date on which the tag was created | None | None |
|
||||
|
||||
#### Musicbrainz fetch task
|
||||
|
||||
To keep Funkwhale's database up-to-date with Musicbrainz's genre tags, we must fetch information from Musicbrainz periodically. We can use the following endpoint to fetch the information:
|
||||
|
||||
```text
|
||||
https://musicbrainz.org/ws/2/genre/all
|
||||
```
|
||||
|
||||
This endpoint accepts the `application/json` header for a JSON response. See the [Musicbrainz API documentation](https://musicbrainz.org/doc/MusicBrainz_API) for more information. The pagination can be controlled by passing the following options:
|
||||
|
||||
- `limit`: the number of results to return
|
||||
- `offset`: the starting point of the page
|
||||
|
||||
The fetch task should fetch **all** pages, using the response `genre-count` to determine how many offset positions to pass.
|
||||
|
||||
```json
|
||||
{
|
||||
"genre-count": 1913,
|
||||
"genre-offset": 24,
|
||||
"genres": [
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "243975aa-1250-4429-8bd3-97080af44cf7",
|
||||
"name": "afro trap"
|
||||
},
|
||||
{
|
||||
"name": "afro-cuban jazz",
|
||||
"id": "cdb11433-1ff1-4c88-be16-717567e1342f",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "afro-funk",
|
||||
"disambiguation": "",
|
||||
"id": "fc00175b-2be9-4d73-ba91-27b3ca827223"
|
||||
},
|
||||
{
|
||||
"name": "afro-jazz",
|
||||
"disambiguation": "",
|
||||
"id": "6f33d775-b4e2-473c-a7db-e34c525cc52d"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "a7e0229c-6e53-45f1-a6f2-a791e78b159e",
|
||||
"name": "afro-zouk"
|
||||
},
|
||||
{
|
||||
"disambiguation": "funk/soul + West African sounds",
|
||||
"id": "fcc58a18-9326-4c92-8b29-c294d44379c3",
|
||||
"name": "afrobeat"
|
||||
},
|
||||
{
|
||||
"id": "b8793fdb-bbc8-4418-a6f8-05eafbbe07ef",
|
||||
"disambiguation": "West African urban/pop music",
|
||||
"name": "afrobeats"
|
||||
},
|
||||
{
|
||||
"name": "afropiano",
|
||||
"id": "d42b567f-0952-424b-959d-bee6e5961cc0",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "52349b68-9cad-496e-8785-00d53f410246",
|
||||
"name": "afroswing"
|
||||
},
|
||||
{
|
||||
"name": "agbadza",
|
||||
"disambiguation": "",
|
||||
"id": "c6d1e78b-ac82-4bb8-89d5-21e3226dc906"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "b8ae0a3c-5826-4104-9663-fe8f828effa9",
|
||||
"name": "agbekor"
|
||||
},
|
||||
{
|
||||
"name": "aggrotech",
|
||||
"disambiguation": "",
|
||||
"id": "c844c144-90a8-4288-981e-e38275592688"
|
||||
},
|
||||
{
|
||||
"name": "ahwash",
|
||||
"id": "4802e6e4-f403-41d1-8e58-76e5cf4df81d",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"id": "50cc5641-b4f9-40b7-bf7a-6d903ac6c1c5",
|
||||
"disambiguation": "",
|
||||
"name": "aita"
|
||||
},
|
||||
{
|
||||
"id": "aebbce35-0e8b-40e9-b04c-bebbbda124d0",
|
||||
"disambiguation": "",
|
||||
"name": "akishibu-kei"
|
||||
},
|
||||
{
|
||||
"name": "al jeel",
|
||||
"disambiguation": "",
|
||||
"id": "0f8d3ff4-8cda-42c4-b462-10352cd01606"
|
||||
},
|
||||
{
|
||||
"name": "algerian chaabi",
|
||||
"id": "998efb76-2f98-41c8-8c5f-74c32e405e9f",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "algorave",
|
||||
"id": "e0a9d0d1-b86f-4344-82a9-022a84627087",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alloukou",
|
||||
"disambiguation": "",
|
||||
"id": "e367c884-d94d-4fba-abc4-8ac51d167ccf"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "ef1d11cc-e70f-4885-ad6c-103f060d33b2",
|
||||
"name": "alpenrock"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "5f9cba3d-1a9f-46cd-8c49-7ed78d1f3354",
|
||||
"name": "alternative country"
|
||||
},
|
||||
{
|
||||
"name": "alternative dance",
|
||||
"id": "8301f73c-9166-4108-bfeb-4fd22dc19083",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alternative folk",
|
||||
"id": "0b48a36c-630f-4ee7-8cf3-480e3dd8be65",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alternative hip hop",
|
||||
"disambiguation": "",
|
||||
"id": "924943cd-73c8-45c0-96eb-74f2a15e5d6e"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "7c4d0994-4c49-4c74-8763-df27fc0084cc",
|
||||
"name": "alté"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The fetch task should run _initially upon first startup_ and then _monthly_ thereafter. The pod admin must be able to disable this job or run it manually at their discretion.
|
||||
|
||||
The task should use the following logic:
|
||||
|
||||
1. Call the Musicbrainz API to fetch new data
|
||||
2. Verify the listed entries against the Funkwhale tag table. The `id` field in the response should be checked against the `musicbrainz_id` field
|
||||
3. Any entries that do not currently exist in Funkwhale should be added with the following mapping:
|
||||
|
||||
| Musicbrainz response field | Tags table column | Notes |
|
||||
| -------------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||
| `id` | `musicbrainz_id` | |
|
||||
| `name` | `display_name` | Funkwhale should automatically generate a Pascal cased `name` based on this entry |
|
||||
|
||||
4. If the `display_name` of a tag **exactly matches** a `name` in the Musicbrainz response but the tag has no `musicbrainz_id`, the `musicbrainz_id` should be populated
|
||||
|
||||
### Frontend behavior
|
||||
|
||||
#### Tagged uploads
|
||||
|
||||
When a user uploads new content with genre tags, the tagged item should be linked to any existing tags and new ones should be created if they're not found.
|
||||
|
||||
#### In-app tagging
|
||||
|
||||
When a user uploads new content with _no_ genre tags, they should be able to select tags from a dropdown menu. This menu is populated with the tags from the database with the `display_name` shown in the list. When a tag is selected, the item is linked to the associated tag.
|
||||
|
||||
If a user inserts a new tag, Funkwhale should:
|
||||
|
||||
1. Store the entered string as the tag's `display_name`
|
||||
2. Generate a Pascal cased `name` for the tag
|
||||
3. Associate the targeted object with the new tag
|
||||
|
||||
#### Search results
|
||||
|
||||
Users should be able to search for tags using Funkwhale's in-app search. In search autocomplete and search results page, the `display_name` should be used. The `name` of the tag should be used to populate the search URL.
|
||||
|
||||
#### Cards
|
||||
|
||||
The `display_name` of the tag should be shown in pills against cards.
|
||||
|
||||
### Admin options
|
||||
|
||||
If the admin of a server wants to **disable** MusicBrainz tagging, they should be able to toggle this in their instance settings. If the setting is **disabled**:
|
||||
|
||||
- The sync task should stop running
|
||||
- Any tags with an `musicbrainz_id` should be excluded from API queries.
|
||||
|
||||
## Availability
|
||||
|
||||
- [x] Admin panel
|
||||
- [x] App frontend
|
||||
- [x] CLI
|
||||
|
||||
## Responsible parties
|
||||
|
||||
- Backend group:
|
||||
- Update the tracks table to support the new information
|
||||
- Update the API to support the new information, or create a new v2 endpoint
|
||||
- Create the new fetch task
|
||||
- Add admin controls for the new task
|
||||
- Frontend group:
|
||||
- Update views to use `display_name` instead of `name` for tag results
|
||||
- Update API calls to use the new API structure created by the backend group
|
||||
- Documentation group:
|
||||
- Document the new task and settings for admins
|
Ładowanie…
Reference in New Issue