Porównaj commity

...

12 Commity

Autor SHA1 Wiadomość Data
petitminion 0281655176 Merge branch '1469-quality-filter-backend' into 'develop'
backend of  "III-5 Quality filter for content"

See merge request funkwhale/funkwhale!2710
2024-04-19 09:47:18 +00:00
Petitminion 4bef27552f upgrade docker postgres dev version to postgres15
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2771>
2024-04-16 13:04:32 +00:00
Ciarán Ainsworth ec368e0cd3 Update from attribute information
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth a2579bdc60 Add from attribute to genre tag spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth e1e0045a23 Add changelog fragment
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 85c2be6a5b fix(docs): run pre-commit
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 35de9bd48e feat(docs): add genre tags spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Petitminion c086770f97 remove comented code 2024-02-29 14:13:52 +01:00
Petitminion 857196cb26 resolve migration error and add migration test 2024-02-06 13:56:54 +01:00
Petitminion a0acdd18a1 add quality filter 2024-01-30 19:52:01 +01:00
Petitminion d94c479799 changlog 2024-01-29 20:02:49 +01:00
Petitminion 2532dbb4a5 add most quality filters and test 2024-01-29 19:53:21 +01:00
14 zmienionych plików z 649 dodań i 3 usunięć

Wyświetl plik

@ -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"

Wyświetl plik

@ -177,6 +177,7 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
size = None
duration = None
mimetype = "audio/ogg"
quality = 1
class Meta:
model = "music.Upload"

Wyświetl plik

@ -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(

Wyświetl plik

@ -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),
]

Wyświetl plik

@ -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):

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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]

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1 @@
Quality filters backend (#2275)

Wyświetl plik

@ -0,0 +1 @@
Add genre tags spec.

Wyświetl plik

@ -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-}

Wyświetl plik

@ -109,6 +109,7 @@ specs/multi-artist/index
specs/user-follow/index
specs/user-deletion/index
specs/upload-process/index
specs/genre-tags/index
```

Wyświetl plik

@ -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