Skip to content
Snippets Groups Projects

Compare revisions

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

Source

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

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 170-federation
  • 594-navigation-redesign
  • 735-table-truncate
  • 839-donation-link
  • 865-sql-optimization
  • 890-notification
  • 925-flac-transcoding
  • add-new-shortcuts
  • develop
  • landing-page
  • limit-album-tracks
  • live-streaming
  • master
  • ollie/funkwhale-documentation-fixes
  • plugins
  • plugins-v2
  • vuln-testing
  • webdav
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
68 results
Show changes
Showing
with 1218 additions and 170 deletions
import json import json
import logging
import sys import sys
import uuid import uuid
import logging
from django.core.management.base import BaseCommand, CommandError
from django.core import validators from django.core import validators
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.common import session from funkwhale_api.common import session
from funkwhale_api.federation import models from funkwhale_api.federation import models
...@@ -71,7 +71,7 @@ class Command(BaseCommand): ...@@ -71,7 +71,7 @@ class Command(BaseCommand):
) )
) )
for name in registry.keys(): for name in registry.keys():
self.stdout.write("- {}".format(name)) self.stdout.write(f"- {name}")
return return
raw_content = None raw_content = None
content = None content = None
...@@ -82,7 +82,8 @@ class Command(BaseCommand): ...@@ -82,7 +82,8 @@ class Command(BaseCommand):
content = models.Activity.objects.get(uuid=input).payload content = models.Activity.objects.get(uuid=input).payload
elif is_url(input): elif is_url(input):
response = session.get_session().get( response = session.get_session().get(
input, headers={"Content-Type": "application/activity+json"}, input,
headers={"Accept": "application/activity+json"},
) )
response.raise_for_status() response.raise_for_status()
content = response.json() content = response.json()
......
# Generated by Django 3.0.4 on 2020-03-17 08:20
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0025_auto_20200317_0820'),
('moderation', '0004_note'),
]
operations = [
migrations.AlterField(
model_name='report',
name='summary',
field=models.TextField(blank=True, max_length=50000, null=True),
),
migrations.CreateModel(
name='UserRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(blank=True, max_length=500, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('handled_date', models.DateTimeField(null=True)),
('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)),
('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')),
('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')),
],
options={
'abstract': False,
},
),
]
# Generated by Django 3.0.8 on 2020-08-03 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moderation', '0005_auto_20200317_0820'),
]
operations = [
migrations.RemoveField(
model_name='userrequest',
name='url',
),
migrations.AlterField(
model_name='userrequest',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'Approved')], default='pending', max_length=40),
),
]
# Generated by Django 3.2.13 on 2022-06-27 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moderation', '0006_auto_20200803_1222'),
]
operations = [
migrations.AlterField(
model_name='report',
name='target_state',
field=models.JSONField(null=True),
),
migrations.AlterField(
model_name='userrequest',
name='metadata',
field=models.JSONField(null=True),
),
]
...@@ -3,8 +3,8 @@ import uuid ...@@ -3,8 +3,8 @@ import uuid
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.db import models from django.db import models
from django.db.models import JSONField
from django.db.models.signals import pre_save from django.db.models.signals import pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
...@@ -185,9 +185,48 @@ class Note(models.Model): ...@@ -185,9 +185,48 @@ class Note(models.Model):
target = GenericForeignKey("target_content_type", "target_id") target = GenericForeignKey("target_content_type", "target_id")
USER_REQUEST_TYPES = [
("signup", "Sign-up"),
]
USER_REQUEST_STATUSES = [
("pending", "Pending"),
("refused", "Refused"),
("approved", "Approved"),
]
class UserRequest(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
handled_date = models.DateTimeField(null=True)
type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES)
status = models.CharField(
max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
)
submitter = models.ForeignKey(
"federation.Actor",
related_name="requests",
on_delete=models.CASCADE,
)
assigned_to = models.ForeignKey(
"federation.Actor",
related_name="assigned_requests",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
metadata = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
@receiver(pre_save, sender=Report) @receiver(pre_save, sender=Report)
def set_handled_date(sender, instance, **kwargs): def set_handled_date(sender, instance, **kwargs):
if instance.is_handled is True and not instance.handled_date: if instance.is_handled and not instance.handled_date:
instance.handled_date = timezone.now() instance.handled_date = timezone.now()
elif not instance.is_handled: elif not instance.is_handled:
instance.handled_date = None instance.handled_date = None
import urllib.parse import urllib.parse
from funkwhale_api.common import preferences from funkwhale_api.common import preferences, utils
from funkwhale_api.common import utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
...@@ -30,16 +29,13 @@ def check_allow_list(payload, **kwargs): ...@@ -30,16 +29,13 @@ def check_allow_list(payload, **kwargs):
utils.recursive_getattr(payload, "object.id", permissive=True), utils.recursive_getattr(payload, "object.id", permissive=True),
] ]
relevant_domains = set( relevant_domains = {
[
domain domain
for domain in [urllib.parse.urlparse(i).hostname for i in relevant_ids if i] for domain in [urllib.parse.urlparse(i).hostname for i in relevant_ids if i]
if domain if domain
] }
)
if relevant_domains - allowed_domains: if relevant_domains - allowed_domains:
raise mrf.Discard( raise mrf.Discard(
"These domains are not allowed: {}".format( "These domains are not allowed: {}".format(
", ".join(relevant_domains - allowed_domains) ", ".join(relevant_domains - allowed_domains)
......
import json import json
import urllib.parse import urllib.parse
import persisting_theory
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
import persisting_theory
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields as common_fields from funkwhale_api.common import fields as common_fields
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
...@@ -13,8 +14,7 @@ from funkwhale_api.federation import utils as federation_utils ...@@ -13,8 +14,7 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models from funkwhale_api.playlists import models as playlists_models
from . import models from . import models, tasks
from . import tasks
class FilteredArtistSerializer(serializers.ModelSerializer): class FilteredArtistSerializer(serializers.ModelSerializer):
...@@ -23,7 +23,7 @@ class FilteredArtistSerializer(serializers.ModelSerializer): ...@@ -23,7 +23,7 @@ class FilteredArtistSerializer(serializers.ModelSerializer):
fields = ["id", "name"] fields = ["id", "name"]
class TargetSerializer(serializers.Serializer): class ModerationTargetSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["artist"]) type = serializers.ChoiceField(choices=["artist"])
id = serializers.CharField() id = serializers.CharField()
...@@ -43,7 +43,7 @@ class TargetSerializer(serializers.Serializer): ...@@ -43,7 +43,7 @@ class TargetSerializer(serializers.Serializer):
class UserFilterSerializer(serializers.ModelSerializer): class UserFilterSerializer(serializers.ModelSerializer):
target = TargetSerializer() target = ModerationTargetSerializer()
class Meta: class Meta:
model = models.UserFilter model = models.UserFilter
...@@ -61,22 +61,57 @@ class UserFilterSerializer(serializers.ModelSerializer): ...@@ -61,22 +61,57 @@ class UserFilterSerializer(serializers.ModelSerializer):
state_serializers = persisting_theory.Registry() state_serializers = persisting_theory.Registry()
class DescriptionStateMixin:
def get_description(self, o):
if o.description:
return o.description.text
TAGS_FIELD = serializers.ListField(source="get_tags") TAGS_FIELD = serializers.ListField(source="get_tags")
@state_serializers.register(name="music.Artist") @state_serializers.register(name="music.Artist")
class ArtistStateSerializer(serializers.ModelSerializer): class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD tags = TAGS_FIELD
class Meta: class Meta:
model = music_models.Artist model = music_models.Artist
fields = ["id", "name", "mbid", "fid", "creation_date", "uuid", "tags"] fields = [
"id",
"name",
"mbid",
"fid",
"creation_date",
"uuid",
"tags",
"content_category",
"description",
]
@state_serializers.register(name="music.ArtistCredit")
class ArtistCreditStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
artist = ArtistStateSerializer()
class Meta:
model = music_models.ArtistCredit
fields = [
"id",
"credit",
"mbid",
"fid",
"creation_date",
"uuid",
"artist",
"joinphrase",
"index",
]
@state_serializers.register(name="music.Album") @state_serializers.register(name="music.Album")
class AlbumStateSerializer(serializers.ModelSerializer): class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD tags = TAGS_FIELD
artist = ArtistStateSerializer() artist_credit = ArtistCreditStateSerializer(many=True)
class Meta: class Meta:
model = music_models.Album model = music_models.Album
...@@ -87,16 +122,17 @@ class AlbumStateSerializer(serializers.ModelSerializer): ...@@ -87,16 +122,17 @@ class AlbumStateSerializer(serializers.ModelSerializer):
"fid", "fid",
"creation_date", "creation_date",
"uuid", "uuid",
"artist", "artist_credit",
"release_date", "release_date",
"tags", "tags",
"description",
] ]
@state_serializers.register(name="music.Track") @state_serializers.register(name="music.Track")
class TrackStateSerializer(serializers.ModelSerializer): class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD tags = TAGS_FIELD
artist = ArtistStateSerializer() artist_credit = ArtistCreditStateSerializer(many=True)
album = AlbumStateSerializer() album = AlbumStateSerializer()
class Meta: class Meta:
...@@ -108,13 +144,14 @@ class TrackStateSerializer(serializers.ModelSerializer): ...@@ -108,13 +144,14 @@ class TrackStateSerializer(serializers.ModelSerializer):
"fid", "fid",
"creation_date", "creation_date",
"uuid", "uuid",
"artist", "artist_credit",
"album", "album",
"disc_number", "disc_number",
"position", "position",
"license", "license",
"copyright", "copyright",
"tags", "tags",
"description",
] ]
...@@ -127,7 +164,6 @@ class LibraryStateSerializer(serializers.ModelSerializer): ...@@ -127,7 +164,6 @@ class LibraryStateSerializer(serializers.ModelSerializer):
"uuid", "uuid",
"fid", "fid",
"name", "name",
"description",
"creation_date", "creation_date",
"privacy_level", "privacy_level",
] ]
...@@ -156,6 +192,36 @@ class ActorStateSerializer(serializers.ModelSerializer): ...@@ -156,6 +192,36 @@ class ActorStateSerializer(serializers.ModelSerializer):
] ]
@state_serializers.register(name="audio.Channel")
class ChannelStateSerializer(serializers.ModelSerializer):
rss_url = serializers.CharField(source="get_rss_url")
name = serializers.CharField(source="artist.name")
full_username = serializers.CharField(source="actor.full_username")
domain = serializers.CharField(source="actor.domain_id")
description = serializers.SerializerMethodField()
tags = serializers.ListField(source="artist.get_tags")
content_category = serializers.CharField(source="artist.content_category")
class Meta:
model = audio_models.Channel
fields = [
"uuid",
"name",
"rss_url",
"metadata",
"full_username",
"description",
"domain",
"creation_date",
"tags",
"content_category",
]
def get_description(self, o):
if o.artist.description:
return o.artist.description.text
def get_actor_query(attr, value): def get_actor_query(attr, value):
data = federation_utils.get_actor_data_from_username(value) data = federation_utils.get_actor_data_from_username(value)
return federation_utils.get_actor_from_username_data_query(None, data) return federation_utils.get_actor_from_username_data_query(None, data)
...@@ -163,11 +229,12 @@ def get_actor_query(attr, value): ...@@ -163,11 +229,12 @@ def get_actor_query(attr, value):
def get_target_owner(target): def get_target_owner(target):
mapping = { mapping = {
audio_models.Channel: lambda t: t.attributed_to,
music_models.Artist: lambda t: t.attributed_to, music_models.Artist: lambda t: t.attributed_to,
music_models.Album: lambda t: t.attributed_to, music_models.Album: lambda t: t.attributed_to,
music_models.Track: lambda t: t.attributed_to, music_models.Track: lambda t: t.attributed_to,
music_models.Library: lambda t: t.actor, music_models.Library: lambda t: t.actor,
playlists_models.Playlist: lambda t: t.user.actor, playlists_models.Playlist: lambda t: t.actor,
federation_models.Actor: lambda t: t, federation_models.Actor: lambda t: t,
} }
...@@ -175,7 +242,13 @@ def get_target_owner(target): ...@@ -175,7 +242,13 @@ def get_target_owner(target):
TARGET_CONFIG = { TARGET_CONFIG = {
"channel": {
"queryset": audio_models.Channel.objects.all(),
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"artist": {"queryset": music_models.Artist.objects.all()}, "artist": {"queryset": music_models.Artist.objects.all()},
"artist_credit": {"queryset": music_models.ArtistCredit.objects.all()},
"album": {"queryset": music_models.Album.objects.all()}, "album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()}, "track": {"queryset": music_models.Track.objects.all()},
"library": { "library": {
...@@ -194,6 +267,27 @@ TARGET_CONFIG = { ...@@ -194,6 +267,27 @@ TARGET_CONFIG = {
TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG) TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG)
def get_target_state(target):
state = {}
target_state_serializer = state_serializers[target._meta.label]
state = target_state_serializer(target).data
# freeze target type/id in JSON so even if the corresponding object is deleted
# we can have the info and display it in the frontend
target_data = TARGET_FIELD.to_representation(target)
state["_target"] = json.loads(json.dumps(target_data, cls=DjangoJSONEncoder))
if "fid" in state:
state["domain"] = urllib.parse.urlparse(state["fid"]).hostname
state["is_local"] = (
state.get("domain", settings.FEDERATION_HOSTNAME)
== settings.FEDERATION_HOSTNAME
)
return state
class ReportSerializer(serializers.ModelSerializer): class ReportSerializer(serializers.ModelSerializer):
target = TARGET_FIELD target = TARGET_FIELD
...@@ -228,35 +322,13 @@ class ReportSerializer(serializers.ModelSerializer): ...@@ -228,35 +322,13 @@ class ReportSerializer(serializers.ModelSerializer):
if not validated_data.get("submitter_email"): if not validated_data.get("submitter_email"):
raise serializers.ValidationError( raise serializers.ValidationError(
"You need to provide an email address to submit this report" "You need to provide an e-mail address to submit this report"
) )
return validated_data return validated_data
def create(self, validated_data): def create(self, validated_data):
target_state_serializer = state_serializers[ validated_data["target_state"] = get_target_state(validated_data["target"])
validated_data["target"]._meta.label
]
validated_data["target_state"] = target_state_serializer(
validated_data["target"]
).data
# freeze target type/id in JSON so even if the corresponding object is deleted
# we can have the info and display it in the frontend
target_data = self.fields["target"].to_representation(validated_data["target"])
validated_data["target_state"]["_target"] = json.loads(
json.dumps(target_data, cls=DjangoJSONEncoder)
)
if "fid" in validated_data["target_state"]:
validated_data["target_state"]["domain"] = urllib.parse.urlparse(
validated_data["target_state"]["fid"]
).hostname
validated_data["target_state"]["is_local"] = (
validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME)
== settings.FEDERATION_HOSTNAME
)
validated_data["target_owner"] = get_target_owner(validated_data["target"]) validated_data["target_owner"] = get_target_owner(validated_data["target"])
r = super().create(validated_data) r = super().create(validated_data)
tasks.signals.report_created.send(sender=None, report=r) tasks.signals.report_created.send(sender=None, report=r)
......
import django.dispatch import django.dispatch
report_created = django.dispatch.Signal(providing_args=["report"]) """ Required argument: report """
report_created = django.dispatch.Signal()
import logging import logging
from django.conf import settings
from django.core import mail from django.core import mail
from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings
from funkwhale_api.common import channels from funkwhale_api.common import channels, preferences, utils
from funkwhale_api.common import utils
from funkwhale_api.taskapp import celery
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.taskapp import celery
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
from . import models from . import models, signals
from . import signals
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -41,11 +41,7 @@ def trigger_moderator_email(report, **kwargs): ...@@ -41,11 +41,7 @@ def trigger_moderator_email(report, **kwargs):
utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk) utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk)
@celery.app.task(name="moderation.send_new_report_email_to_moderators") def get_moderators():
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
moderators = users_models.User.objects.filter( moderators = users_models.User.objects.filter(
is_active=True, permission_moderation=True is_active=True, permission_moderation=True
) )
...@@ -53,15 +49,22 @@ def send_new_report_email_to_moderators(report): ...@@ -53,15 +49,22 @@ def send_new_report_email_to_moderators(report):
# we fallback on superusers # we fallback on superusers
moderators = users_models.User.objects.filter(is_superuser=True) moderators = users_models.User.objects.filter(is_superuser=True)
moderators = sorted(moderators, key=lambda m: m.pk) moderators = sorted(moderators, key=lambda m: m.pk)
return moderators
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
moderators = get_moderators()
submitter_repr = ( submitter_repr = (
report.submitter.full_username if report.submitter else report.submitter_email report.submitter.full_username if report.submitter else report.submitter_email
) )
subject = "[{} moderation - {}] New report from {}".format( subject = "[{} moderation - {}] New report from {}".format(
settings.FUNKWHALE_HOSTNAME, report.get_type_display(), submitter_repr settings.FUNKWHALE_HOSTNAME, report.get_type_display(), submitter_repr
) )
detail_url = federation_utils.full_url( detail_url = federation_utils.full_url(f"/manage/moderation/reports/{report.uuid}")
"/manage/moderation/reports/{}".format(report.uuid)
)
unresolved_reports_url = federation_utils.full_url( unresolved_reports_url = federation_utils.full_url(
"/manage/moderation/reports?q=resolved:no" "/manage/moderation/reports?q=resolved:no"
) )
...@@ -92,21 +95,23 @@ def send_new_report_email_to_moderators(report): ...@@ -92,21 +95,23 @@ def send_new_report_email_to_moderators(report):
body += [ body += [
"", "",
"- To handle this report, please visit {}".format(detail_url), f"- To handle this report, please visit {detail_url}",
"- To view all unresolved reports (currently {}), please visit {}".format( "- To view all unresolved reports (currently {}), please visit {}".format(
unresolved_reports, unresolved_reports_url unresolved_reports, unresolved_reports_url
), ),
"", "",
"", "",
"", "",
"You are receiving this email because you are a moderator for {}.".format( "You are receiving this e-mail because you are a moderator for {}.".format(
settings.FUNKWHALE_HOSTNAME settings.FUNKWHALE_HOSTNAME
), ),
] ]
for moderator in moderators: for moderator in moderators:
if not moderator.email: if not moderator.email:
logger.warning("Moderator %s has no email configured", moderator.username) logger.warning(
"Moderator %s has no e-mail address configured", moderator.username
)
continue continue
mail.send_mail( mail.send_mail(
subject, subject,
...@@ -114,3 +119,148 @@ def send_new_report_email_to_moderators(report): ...@@ -114,3 +119,148 @@ def send_new_report_email_to_moderators(report):
recipient_list=[moderator.email], recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
) )
@celery.app.task(name="moderation.user_request_handle")
@celery.require_instance(
models.UserRequest.objects.select_related("submitter"), "user_request"
)
@transaction.atomic
def user_request_handle(user_request, new_status, old_status=None):
if user_request.status != new_status:
logger.warn(
"User request %s was handled before asynchronous tasks run", user_request.pk
)
return
if user_request.type == "signup" and new_status == "pending" and old_status is None:
notify_mods_signup_request_pending(user_request)
broadcast_user_request_created(user_request)
elif user_request.type == "signup" and new_status == "approved":
user_request.submitter.user.is_active = True
user_request.submitter.user.save(update_fields=["is_active"])
notify_submitter_signup_request_approved(user_request)
elif user_request.type == "signup" and new_status == "refused":
notify_submitter_signup_request_refused(user_request)
def broadcast_user_request_created(user_request):
from funkwhale_api.manage import serializers as manage_serializers
channels.group_send(
"admin.moderation",
{
"type": "event.send",
"text": "",
"data": {
"type": "user_request.created",
"user_request": manage_serializers.ManageUserRequestSerializer(
user_request
).data,
"pending_count": models.UserRequest.objects.filter(
status="pending"
).count(),
},
},
)
def notify_mods_signup_request_pending(obj):
moderators = get_moderators()
submitter_repr = obj.submitter.preferred_username
subject = "[{} moderation] New sign-up request from {}".format(
settings.FUNKWHALE_HOSTNAME, submitter_repr
)
detail_url = federation_utils.full_url(f"/manage/moderation/requests/{obj.uuid}")
unresolved_requests_url = federation_utils.full_url(
"/manage/moderation/requests?q=status:pending"
)
unresolved_requests = models.UserRequest.objects.filter(status="pending").count()
body = [
"{} wants to register on your pod. You need to review their request before they can use the service.".format(
submitter_repr
),
"",
f"- To handle this request, please visit {detail_url}",
"- To view all unresolved requests (currently {}), please visit {}".format(
unresolved_requests, unresolved_requests_url
),
"",
"",
"",
"You are receiving this e-mail because you are a moderator for {}.".format(
settings.FUNKWHALE_HOSTNAME
),
]
for moderator in moderators:
if not moderator.email:
logger.warning(
"Moderator %s has no e-mail address configured", moderator.username
)
continue
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_approved(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no e-mail address configured", submitter_repr)
return
subject = f"Welcome to {settings.FUNKWHALE_HOSTNAME}, {submitter_repr}!"
login_url = federation_utils.full_url("/login")
body = [
f"Hi {submitter_repr} and welcome,",
"",
"Our moderation team has approved your account request and you can now start "
"using the service. Please visit {} to get started.".format(login_url),
"",
"Before your first login, you may need to verify your e-mail address if you didn't already.",
]
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[submitter_email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_refused(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no e-mail address configured", submitter_repr)
return
subject = "Your account request at {} was refused".format(
settings.FUNKWHALE_HOSTNAME
)
body = [
f"Hi {submitter_repr},",
"",
"You recently submitted an account request on our service. However, our "
"moderation team has refused it, and as a result, you won't be able to use "
"the service.",
]
instance_contact_email = preferences.get("instance__contact_email")
if instance_contact_email:
body += [
"",
"If you think this is a mistake, please contact our team at {}.".format(
instance_contact_email
),
]
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[submitter_email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
...@@ -5,13 +5,17 @@ from funkwhale_api.federation import models as federation_models ...@@ -5,13 +5,17 @@ from funkwhale_api.federation import models as federation_models
from . import models from . import models
from . import serializers as moderation_serializers from . import serializers as moderation_serializers
NOTE_TARGET_FIELDS = { NOTE_TARGET_FIELDS = {
"report": { "report": {
"queryset": models.Report.objects.all(), "queryset": models.Report.objects.all(),
"id_attr": "uuid", "id_attr": "uuid",
"id_field": serializers.UUIDField(), "id_field": serializers.UUIDField(),
}, },
"request": {
"queryset": models.UserRequest.objects.all(),
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"account": { "account": {
"queryset": federation_models.Actor.objects.all(), "queryset": federation_models.Actor.objects.all(),
"id_attr": "full_username", "id_attr": "full_username",
...@@ -19,3 +23,21 @@ NOTE_TARGET_FIELDS = { ...@@ -19,3 +23,21 @@ NOTE_TARGET_FIELDS = {
"get_query": moderation_serializers.get_actor_query, "get_query": moderation_serializers.get_actor_query,
}, },
} }
def get_signup_form_additional_fields_serializer(customization):
fields = (customization or {}).get("fields", []) or []
class AdditionalFieldsSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in fields:
required = bool(field.get("required", True))
self.fields[field["label"]] = serializers.CharField(
max_length=5000,
required=required,
allow_null=not required,
allow_blank=not required,
)
return AdditionalFieldsSerializer(required=fields, allow_null=not fields)
from django.db import IntegrityError from django.db import IntegrityError
from rest_framework import mixins, response, status, viewsets
from rest_framework import mixins from funkwhale_api.federation import routes
from rest_framework import response from funkwhale_api.federation import utils as federation_utils
from rest_framework import status
from rest_framework import viewsets
from . import models from . import models, serializers
from . import serializers
class UserFilterViewSet( class UserFilterViewSet(
...@@ -66,4 +64,13 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): ...@@ -66,4 +64,13 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
submitter = None submitter = None
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
submitter = self.request.user.actor submitter = self.request.user.actor
serializer.save(submitter=submitter) report = serializer.save(submitter=submitter)
forward = self.request.data.get("forward", False)
if (
forward
and report.target
and report.target_owner
and hasattr(report.target, "fid")
and not federation_utils.is_local(report.target.fid)
):
routes.outbox.dispatch({"type": "Flag"}, context={"report": report})
...@@ -3,24 +3,61 @@ from funkwhale_api.common import admin ...@@ -3,24 +3,61 @@ from funkwhale_api.common import admin
from . import models from . import models
@admin.register(models.ArtistCredit)
class ArtistCreditAdmin(admin.ModelAdmin):
list_display = [
"artist",
"credit",
"joinphrase",
"creation_date",
]
search_fields = ["artist__name", "credit"]
@admin.register(models.Artist) @admin.register(models.Artist)
class ArtistAdmin(admin.ModelAdmin): class ArtistAdmin(admin.ModelAdmin):
list_display = ["name", "mbid", "creation_date"] list_display = ["name", "mbid", "creation_date", "modification_date"]
search_fields = ["name", "mbid"] search_fields = ["name", "mbid"]
@admin.register(models.Album) @admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "mbid", "release_date", "creation_date"] list_display = ["title", "mbid", "release_date", "creation_date"]
search_fields = ["title", "artist__name", "mbid"] search_fields = ["title", "mbid"]
list_select_related = True list_select_related = True
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == "artist_credit":
object_id = request.resolver_match.kwargs.get("object_id")
kwargs["queryset"] = models.ArtistCredit.objects.filter(
albums__id=object_id
)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(models.Track) @admin.register(models.Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "album", "mbid"] list_display = ["title", "album", "mbid", "artist"]
search_fields = ["title", "artist__name", "album__title", "mbid"] search_fields = ["title", "album__title", "mbid"]
list_select_related = ["album__artist", "artist"]
def artist(self, obj):
return obj.get_artist_credit_string
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == "artist_credit":
object_id = request.resolver_match.kwargs.get("object_id")
kwargs["queryset"] = models.ArtistCredit.objects.filter(
tracks__id=object_id
)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(models.TrackActor)
class TrackActorAdmin(admin.ModelAdmin):
list_display = ["actor", "track", "upload", "internal"]
search_fields = ["actor__preferred_username", "track__name"]
list_select_related = ["actor", "track"]
@admin.register(models.ImportBatch) @admin.register(models.ImportBatch)
...@@ -61,6 +98,14 @@ class UploadAdmin(admin.ModelAdmin): ...@@ -61,6 +98,14 @@ class UploadAdmin(admin.ModelAdmin):
] ]
list_filter = ["mimetype", "import_status", "library__privacy_level"] list_filter = ["mimetype", "import_status", "library__privacy_level"]
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == "playlist_libraries":
object_id = request.resolver_match.kwargs.get("object_id")
kwargs["queryset"] = models.Library.objects.filter(
playlist_uploads=object_id
).distinct()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(models.UploadVersion) @admin.register(models.UploadVersion)
class UploadVersionAdmin(admin.ModelAdmin): class UploadVersionAdmin(admin.ModelAdmin):
...@@ -96,7 +141,7 @@ launch_scan.short_description = "Launch scan" ...@@ -96,7 +141,7 @@ launch_scan.short_description = "Launch scan"
class LibraryAdmin(admin.ModelAdmin): class LibraryAdmin(admin.ModelAdmin):
list_display = ["id", "name", "actor", "uuid", "privacy_level", "creation_date"] list_display = ["id", "name", "actor", "uuid", "privacy_level", "creation_date"]
list_select_related = True list_select_related = True
search_fields = ["actor__username", "name", "description"] search_fields = ["uuid", "name", "actor__preferred_username"]
list_filter = ["privacy_level"] list_filter = ["privacy_level"]
actions = [launch_scan] actions = [launch_scan]
......
from django.forms import widgets
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
music = types.Section("music") music = types.Section("music")
quality_filters = types.Section("quality_filters")
@global_preferences_registry.register @global_preferences_registry.register
...@@ -27,8 +29,176 @@ class MusicCacheDuration(types.IntPreference): ...@@ -27,8 +29,176 @@ class MusicCacheDuration(types.IntPreference):
default = 60 * 24 * 7 default = 60 * 24 * 7
verbose_name = "Transcoding cache duration" verbose_name = "Transcoding cache duration"
help_text = ( help_text = (
"How much minutes do you want to keep a copy of transcoded tracks " "How many minutes do you want to keep a copy of transcoded tracks "
"on the server? Transcoded files that were not listened in this interval " "on the server? Transcoded files that were not listened in this interval "
"will be erased and retranscoded on the next listening." "will be erased and retranscoded on the next listening."
) )
field_kwargs = {"required": False} field_kwargs = {"required": False}
@global_preferences_registry.register
class MbidTaggedContent(types.BooleanPreference):
show_in_api = True
section = music
name = "only_allow_musicbrainz_tagged_files"
verbose_name = "Only allow Musicbrainz tagged files"
help_text = (
"Requires uploaded files to be tagged with a MusicBrainz ID. "
"Enabling this setting has no impact on previously uploaded files. "
"You can use the CLI to clear files that don't contain an MBID or "
"or enable quality filtering to hide untagged content from API calls. "
)
default = False
@global_preferences_registry.register
class MbGenreTags(types.BooleanPreference):
show_in_api = True
section = music
name = "musicbrainz_genre_update"
verbose_name = "Prepopulate tags with MusicBrainz Genre "
help_text = (
"Will trigger a monthly update of the tag table "
"using Musicbrainz genres. Non-existing tag will be created and "
"MusicBrainz Ids will be added to the tags if "
"they match the genre name."
)
default = True
@global_preferences_registry.register
class MbSyncTags(types.BooleanPreference):
show_in_api = True
section = music
name = "sync_musicbrainz_tags"
verbose_name = "Sync MusicBrainz to to funkwhale objects"
help_text = (
"If uploaded files are tagged with a MusicBrainz ID, "
"Funkwhale will query MusicBrainz server to add tags to "
"the track, artist and album objects."
)
default = False
# quality_filters section. Note that the default False is not applied in the fronted
# (the filter will onlyu be use if set to True)
@global_preferences_registry.register
class BitrateFilter(types.ChoicePreference):
show_in_api = True
section = quality_filters
name = "bitrate_filter"
verbose_name = "Upload Quality Filter"
default = "low"
choices = [
("low", "Allow all audio qualities"),
("medium", "Medium : Do not allow low quality"),
("high", "High : only allow high and very-high audio qualities"),
("very_high", "Very High : only allow very-high audio quality"),
]
help_text = (
"The main page content can be filtered based on audio quality. "
"This will exclude lower quality, higher qualities are never excluded. "
"Quality Table can be found in the docs."
)
field_kwargs = {"choices": choices, "required": False}
@global_preferences_registry.register
class HasMbid(types.BooleanPreference):
show_in_api = True
section = quality_filters
name = "has_mbid"
verbose_name = "Musicbrainz Ids filter"
help_text = "Should we filter out metadata without Musicbrainz Ids ?"
default = False
@global_preferences_registry.register
class Format(types.MultipleChoicePreference):
show_in_api = True
section = quality_filters
name = "format"
verbose_name = "Allowed Audio Format"
default = (["aac", "aif", "aiff", "flac", "mp3", "ogg", "opus"],)
choices = [
("ogg", "ogg"),
("opus", "opus"),
("flac", "flac"),
("aif", "aif"),
("aiff", "aiff"),
("aac", "aac"),
("mp3", "mp3"),
]
help_text = "Which audio format to allow"
@global_preferences_registry.register
class AlbumArt(types.BooleanPreference):
show_in_api = True
section = quality_filters
name = "has_cover"
verbose_name = "Album art Filter"
help_text = "Only Albums with a cover will be displayed in the home page"
default = False
@global_preferences_registry.register
class Tags(types.BooleanPreference):
show_in_api = True
section = quality_filters
name = "has_tags"
verbose_name = "Tags Filter"
help_text = "Only content with at least one tag will be displayed"
default = False
@global_preferences_registry.register
class ReleaseDate(types.BooleanPreference):
show_in_api = True
section = quality_filters
name = "has_release_date"
verbose_name = "Release date Filter"
help_text = "Only content with a release date will be displayed"
default = False
@global_preferences_registry.register
class JoinPhrases(types.StringPreference):
show_in_api = True
section = music
name = "join_phrases"
verbose_name = "Join Phrases"
help_text = (
"Used by the artist parser to create multiples artists in case the metadata "
"is a single string. BE WARNED, changing the order or the values can break the parser in unexpected ways. "
"It's MANDATORY to escape dots and to put doted variation before because the first match is used "
r"(example : `|feat\.|ft\.|feat|` and not `feat|feat\.|ft\.|feat`.). ORDER is really important "
"(says an anarchist). To avoid artist duplication and wrongly parsed artist data "
"it's recommended to tag files with Musicbrainz Picard. "
)
default = (
r"featuring | feat\. | ft\. | feat | with | and | & | vs\. | \| | \||\| |\|| , | ,|, |,|"
r" ; | ;|; |;| versus | vs | \( | \(|\( |\(| Remix\) |Remix\) | Remix\)| \) | \)|\) |\)| x |"
"accompanied by | alongside | together with | collaboration with | featuring special guest |"
"joined by | joined with | featuring guest | introducing | accompanied by | performed by | performed with |"
"performed by and | and | featuring | with | presenting | accompanied by | and special guest |"
"featuring special guests | featuring and | featuring & | and featuring "
)
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class DefaultJoinPhrases(types.StringPreference):
show_in_api = True
section = music
name = "default_join_phrase"
verbose_name = "Default Join Phrase"
help_text = (
"The default join phrase used by artist parser"
"For example: `artists = [artist1, Artist2]` will be displayed has : artist1.name, artis2.name"
"Changing this value will not update already parsed artists"
)
default = ", "
widget = widgets.Textarea
field_kwargs = {"required": False}
import os import os
from urllib.parse import urlparse
import factory import factory
from django.conf import settings
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.common import factories as common_factories from funkwhale_api.common import factories as common_factories
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import factories as federation_factories from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.music import licenses from funkwhale_api.music import licenses
from funkwhale_api.tags import factories as tags_factories from funkwhale_api.tags import factories as tags_factories
...@@ -63,7 +64,7 @@ class ArtistFactory( ...@@ -63,7 +64,7 @@ class ArtistFactory(
name = factory.Faker("name") name = factory.Faker("name")
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist") playable = playable_factory("track__album__artist_credit__artist")
class Meta: class Meta:
model = "music.Artist" model = "music.Artist"
...@@ -73,6 +74,24 @@ class ArtistFactory( ...@@ -73,6 +74,24 @@ class ArtistFactory(
attributed_to=factory.SubFactory(federation_factories.ActorFactory) attributed_to=factory.SubFactory(federation_factories.ActorFactory)
) )
local = factory.Trait(fid=factory.Faker("federation_url", local=True)) local = factory.Trait(fid=factory.Faker("federation_url", local=True))
with_cover = factory.Trait(
attachment_cover=factory.SubFactory(common_factories.AttachmentFactory)
)
@registry.register
class ArtistCreditFactory(factory.django.DjangoModelFactory):
artist = factory.SubFactory(ArtistFactory)
credit = factory.LazyAttribute(lambda obj: obj.artist.name)
joinphrase = ""
class Meta:
model = "music.ArtistCredit"
class Params:
local = factory.Trait(
artist=factory.SubFactory(ArtistFactory, local=True),
)
@registry.register @registry.register
...@@ -82,8 +101,6 @@ class AlbumFactory( ...@@ -82,8 +101,6 @@ class AlbumFactory(
title = factory.Faker("sentence", nb_words=3) title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
release_date = factory.Faker("date_object") release_date = factory.Faker("date_object")
attachment_cover = factory.SubFactory(common_factories.AttachmentFactory)
artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker("uuid4") release_group_id = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
playable = playable_factory("track__album") playable = playable_factory("track__album")
...@@ -95,16 +112,28 @@ class AlbumFactory( ...@@ -95,16 +112,28 @@ class AlbumFactory(
attributed = factory.Trait( attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory) attributed_to=factory.SubFactory(federation_factories.ActorFactory)
) )
local = factory.Trait( local = factory.Trait(
fid=factory.Faker("federation_url", local=True), artist__local=True fid=factory.Faker("federation_url", local=True),
)
with_cover = factory.Trait(
attachment_cover=factory.SubFactory(common_factories.AttachmentFactory)
) )
@factory.post_generation
def artist_credit(self, create, extracted, **kwargs):
if urlparse(self.fid).netloc == settings.FEDERATION_HOSTNAME:
kwargs["artist__local"] = True
if extracted:
self.artist_credit.add(extracted)
if create:
self.artist_credit.add(ArtistCreditFactory(**kwargs))
@registry.register @registry.register
class TrackFactory( class TrackFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
): ):
uuid = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
title = factory.Faker("sentence", nb_words=3) title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
...@@ -121,31 +150,48 @@ class TrackFactory( ...@@ -121,31 +150,48 @@ class TrackFactory(
) )
local = factory.Trait( local = factory.Trait(
fid=factory.Faker("federation_url", local=True), album__local=True fid=factory.Faker(
"federation_url",
local=True,
prefix="/federation/music/tracks",
obj_uuid=factory.SelfAttribute("..uuid"),
),
album__local=True,
)
with_cover = factory.Trait(
attachment_cover=factory.SubFactory(common_factories.AttachmentFactory)
) )
@factory.post_generation @factory.post_generation
def artist(self, created, extracted, **kwargs): def artist_credit(self, created, extracted, **kwargs):
""" """
A bit intricated, because we want to be able to specify a different A bit intricated, because we want to be able to specify a different
track artist with a fallback on album artist if nothing is specified. track artist with a fallback on album artist if nothing is specified.
And handle cases where build or build_batch are used (so no db calls) And handle cases where build or build_batch are used (so no db calls)
""" """
# needed to get a primary key on the track and album objects. The primary key is needed for many_to_many
if self.album:
self.album.save()
if not self.pk:
self.save()
if extracted: if extracted:
self.artist = extracted self.artist_credit.add(extracted)
elif kwargs: elif kwargs:
if created: if created:
self.artist = ArtistFactory(**kwargs) self.artist_credit.add(ArtistCreditFactory(**kwargs))
else: else:
self.artist = ArtistFactory.build(**kwargs) self.artist_credit.add(ArtistCreditFactory.build(**kwargs))
elif self.album: elif self.album:
self.artist = self.album.artist self.artist_credit.set(self.album.artist_credit.all())
if created: if created:
self.save() self.save()
@factory.post_generation # The @factory.post_generation is not used because we must
def license(self, created, extracted, **kwargs): # not redefine the builtin `license` function.
def _license_post_generation(self, created, extracted, **kwargs):
if not created: if not created:
return return
...@@ -153,6 +199,8 @@ class TrackFactory( ...@@ -153,6 +199,8 @@ class TrackFactory(
self.license = LicenseFactory(code=extracted) self.license = LicenseFactory(code=extracted)
self.save() self.save()
license = factory.PostGeneration(_license_post_generation)
@registry.register @registry.register
class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
...@@ -167,6 +215,7 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -167,6 +215,7 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
size = None size = None
duration = None duration = None
mimetype = "audio/ogg" mimetype = "audio/ogg"
quality = 1
class Meta: class Meta:
model = "music.Upload" model = "music.Upload"
...@@ -176,6 +225,23 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -176,6 +225,23 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
playable = factory.Trait( playable = factory.Trait(
import_status="finished", library__privacy_level="everyone" import_status="finished", library__privacy_level="everyone"
) )
local = factory.Trait(
fid=factory.Faker("federation_url", local=True),
track__local=True,
library__local=True,
)
@factory.post_generation
def channel(self, created, extracted, **kwargs):
if not extracted:
return
from funkwhale_api.audio import factories as audio_factories
audio_factories.ChannelFactory(
library=self.library,
artist=self.track.artist_credit.all()[0].artist,
**kwargs
)
@registry.register @registry.register
......
""" """
Populates the database with fake data Populates the database with fake data
""" """
import logging
import random import random
from funkwhale_api.music import factories from funkwhale_api.audio import factories as audio_factories
from funkwhale_api.cli import users
from funkwhale_api.favorites import factories as favorites_factories
from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.history import factories as history_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.playlists import factories as playlist_factories
from funkwhale_api.users import models, serializers
logger = logging.getLogger(__name__)
def create_data(super_user_name=None):
super_user = None
if super_user_name:
try:
super_user = users.handler_create_user(
username=str(super_user_name),
password="funkwhale",
email=f"{super_user_name}eat@the.rich",
is_superuser=True,
is_staff=True,
upload_quota=None,
)
except serializers.ValidationError as e:
for field, errors in e.detail.items():
if (
"A user with that username already exists"
or "A user is already registered with this e-mail address"
in errors[0]
):
print(
f"Superuser {super_user_name} already in db. Skipping superuser creation"
)
super_user = models.User.objects.get(username=super_user_name)
continue
else:
raise e
print(f"Superuser with username {super_user_name} and password `funkwhale`")
library = federation_factories.MusicLibraryFactory(
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
local=True if super_user else False,
)
uploads = music_factories.UploadFactory.create_batch(
size=random.randint(3, 18),
playable=True,
library=library,
local=True,
)
for upload in uploads[:2]:
history_factories.ListeningFactory(
track=upload.track, actor=upload.library.actor
)
favorites_factories.TrackFavorite(
track=upload.track, actor=upload.library.actor
)
print("Created fid", upload.track.fid)
playlist = playlist_factories.PlaylistFactory(
name="playlist test public",
privacy_level="everyone",
local=True if super_user else False,
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
)
playlist_factories.PlaylistTrackFactory(playlist=playlist, track=upload.track)
federation_factories.LibraryFollowFactory.create_batch(
size=random.randint(3, 18), actor=super_user.actor
)
def create_data(count=25): # my podcast
artists = factories.ArtistFactory.create_batch(size=count) my_podcast_library = federation_factories.MusicLibraryFactory(
for artist in artists: actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
print("Creating data for", artist) local=True,
albums = factories.AlbumFactory.create_batch( )
artist=artist, size=random.randint(1, 5) my_podcast_channel = audio_factories.ChannelFactory(
library=my_podcast_library,
attributed_to=super_user.actor,
artist__content_category="podcast",
)
my_podcast_channel_serie = music_factories.AlbumFactory(
artist_credit__artist=my_podcast_channel.artist
) )
for album in albums: music_factories.TrackFactory.create_batch(
factories.UploadFactory.create_batch( size=random.randint(3, 6),
track__album=album, size=random.randint(3, 18) artist_credit__artist=my_podcast_channel.artist,
album=my_podcast_channel_serie,
)
# podcast
podcast_channel = audio_factories.ChannelFactory(artist__content_category="podcast")
podcast_channel_serie = music_factories.AlbumFactory(
artist_credit__artist=podcast_channel.artist
)
music_factories.TrackFactory.create_batch(
size=random.randint(3, 6),
artist_credit__artist=podcast_channel.artist,
album=podcast_channel_serie,
)
audio_factories.SubscriptionFactory(
approved=True, target=podcast_channel.actor, actor=super_user.actor
)
# my artist channel
my_artist_library = federation_factories.MusicLibraryFactory(
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
local=True if super_user else False,
)
my_artist_channel = audio_factories.ChannelFactory(
library=my_artist_library,
attributed_to=super_user.actor,
artist__content_category="music",
)
my_artist_channel_serie = music_factories.AlbumFactory(
artist_credit__artist=my_artist_channel.artist
)
music_factories.TrackFactory.create_batch(
size=random.randint(3, 6),
artist_credit__artist=my_artist_channel.artist,
album=my_artist_channel_serie,
)
# artist channel
artist_channel = audio_factories.ChannelFactory(artist__content_category="artist")
artist_channel_serie = music_factories.AlbumFactory(
artist_credit__artist=artist_channel.artist
)
music_factories.TrackFactory.create_batch(
size=random.randint(3, 6),
artist_credit__artist=artist_channel.artist,
album=artist_channel_serie,
)
audio_factories.SubscriptionFactory(
approved=True, target=artist_channel.actor, actor=super_user.actor
) )
......
import django_filters
from django.db.models import Q
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.audio import filters as audio_filters from funkwhale_api.audio import filters as audio_filters
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import filters as common_filters from funkwhale_api.common import filters as common_filters
from funkwhale_api.common import search from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.tags import filters as tags_filters
from . import models from . import models, utils
from . import utils
def filter_tags(queryset, name, value): def filter_tags(queryset, name, value):
non_empty_tags = [v.lower() for v in value if v] non_empty_tags = [v.lower() for v in value if v]
for tag in non_empty_tags: for tag in non_empty_tags:
queryset = queryset.filter(tagged_items__tag__name=tag).distinct() queryset = queryset.filter(tagged_items__tag__name__iexact=tag).distinct()
return queryset return queryset
TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags) TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags)
class RelatedFilterSet(filters.FilterSet):
related_type = int
related_field = "pk"
related = filters.CharFilter(field_name="_", method="filter_related")
def filter_related(self, queryset, name, value):
if not value:
return queryset.none()
try:
pk = self.related_type(value)
except (TypeError, ValueError):
return queryset.none()
try:
obj = queryset.model.objects.get(**{self.related_field: pk})
except queryset.model.DoesNotExist:
return queryset.none()
queryset = queryset.exclude(pk=obj.pk)
return tags_filters.get_by_similar_tags(queryset, obj.get_tags())
class ChannelFilterSet(filters.FilterSet):
channel = filters.CharFilter(field_name="_", method="filter_channel")
def filter_channel(self, queryset, name, value):
if not value:
return queryset
channel = (
audio_models.Channel.objects.filter(uuid=value)
.select_related("library")
.first()
)
if not channel:
return queryset.none()
uploads = models.Upload.objects.filter(library=channel.library)
actor = utils.get_actor_from_request(self.request)
uploads = uploads.playable_by(actor)
ids = uploads.values_list(self.Meta.channel_filter_field, flat=True)
return queryset.filter(pk__in=ids).distinct()
class LibraryFilterSet(filters.FilterSet):
library = filters.CharFilter(field_name="_", method="filter_library")
def filter_library(self, queryset, name, value):
if not value:
return queryset
actor = utils.get_actor_from_request(self.request)
library = models.Library.objects.filter(uuid=value).viewable_by(actor).first()
if not library:
return queryset.none()
uploads = models.Upload.objects.filter(library=library)
uploads = uploads.playable_by(actor)
ids = uploads.values_list(self.Meta.library_filter_field, flat=True)
qs = queryset.filter(pk__in=ids).distinct()
return qs
class ArtistFilter( class ArtistFilter(
audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet RelatedFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
): ):
q = fields.SearchFilter(search_fields=["name"]) q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
has_albums = filters.BooleanFilter(field_name="_", method="filter_has_albums")
tag = TAG_FILTER tag = TAG_FILTER
content_category = filters.CharFilter("content_category")
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor", distinct=True actor_field="artist_credit__tracks__uploads__library__actor",
distinct=True,
library_field="artist_credit__tracks__uploads__library",
)
ordering = common_filters.CaseInsensitiveNameOrderingFilter(
fields=(
("id", "id"),
("name", "name"),
("creation_date", "creation_date"),
("modification_date", "modification_date"),
("?", "random"),
("tag_matches", "related"),
)
)
has_mbid = filters.BooleanFilter(
field_name="_",
method="filter_has_mbid",
) )
class Meta: class Meta:
model = models.Artist model = models.Artist
fields = { fields = {
"name": ["exact", "iexact", "startswith", "icontains"], "name": ["exact", "iexact", "startswith", "icontains"],
"playable": ["exact"],
"scope": ["exact"],
"mbid": ["exact"], "mbid": ["exact"],
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel" include_channels_field = "channel"
library_filter_field = "track__artist_credit__artist"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value) return queryset.playable_by(actor, value).distinct()
def filter_has_albums(self, queryset, name, value):
return queryset.filter(artist_credit__albums__isnull=not value)
def filter_has_mbid(self, queryset, name, value):
return queryset.filter(mbid__isnull=(not value))
class TrackFilter( class TrackFilter(
audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet RelatedFilterSet,
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
): ):
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) q = fields.SearchFilter(
search_fields=[
"title",
"album__title",
"artist_credit__artist__name",
],
fts_search_fields=[
"body_text",
"artist_credit__artist__body_text",
"album__body_text",
],
)
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
tag = TAG_FILTER tag = TAG_FILTER
id = common_filters.MultipleQueryFilter(coerce=int) id = common_filters.MultipleQueryFilter(coerce=int)
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
actor_field="uploads__library__actor", distinct=True actor_field="uploads__library__actor",
library_field="uploads__library",
distinct=True,
)
artist = filters.ModelChoiceFilter(
field_name="_", method="filter_artist", queryset=models.Artist.objects.all()
)
ordering = django_filters.OrderingFilter(
fields=(
("creation_date", "creation_date"),
("title", "title"),
("album__title", "album__title"),
("album__release_date", "album__release_date"),
("size", "size"),
("position", "position"),
("disc_number", "disc_number"),
("artist_credit__artist__name", "artist_credit__artist__name"),
(
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"),
("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: class Meta:
model = models.Track model = models.Track
fields = { fields = {
"title": ["exact", "iexact", "startswith", "icontains"], "title": ["exact", "iexact", "startswith", "icontains"],
"playable": ["exact"],
"id": ["exact"], "id": ["exact"],
"artist": ["exact"],
"album": ["exact"], "album": ["exact"],
"license": ["exact"], "license": ["exact"],
"scope": ["exact"],
"mbid": ["exact"], "mbid": ["exact"],
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
include_channels_field = "artist__channel" include_channels_field = "artist_credit__artist__channel"
channel_filter_field = "track"
library_filter_field = "track"
artist_credit_filter_field = "artist__credit__artist"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value) return queryset.playable_by(actor, value).distinct()
def filter_artist(self, queryset, name, value):
return queryset.filter(
Q(artist_credit__artist=value) | Q(album__artist_credit__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): class UploadFilter(audio_filters.IncludeChannelsFilterSet):
library = filters.CharFilter("library__uuid") library = filters.CharFilter("library__uuid")
channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid") track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid") track_artist = filters.UUIDFilter("track__artist_credit__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid") album_artist = filters.UUIDFilter("track__album__artist_credit__artist__uuid")
library = filters.UUIDFilter("library__uuid") library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True) scope = common_filters.ActorScopeFilter(
actor_field="library__actor",
distinct=True,
library_field="library",
)
import_status = common_filters.MultipleQueryFilter(coerce=str, distinct=False)
q = fields.SmartSearchFilter( q = fields.SmartSearchFilter(
config=search.SearchConfig( config=search.SearchConfig(
search_fields={ search_fields={
...@@ -106,17 +283,11 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet): ...@@ -106,17 +283,11 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
class Meta: class Meta:
model = models.Upload model = models.Upload
fields = [ fields = [
"playable",
"import_status", "import_status",
"mimetype", "mimetype",
"track",
"track_artist",
"album_artist",
"library",
"import_reference", "import_reference",
"scope",
] ]
include_channels_field = "track__artist__channel" include_channels_field = "track__artist_credit__artist__channel"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
...@@ -124,21 +295,99 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet): ...@@ -124,21 +295,99 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
class AlbumFilter( class AlbumFilter(
audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet RelatedFilterSet,
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
): ):
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(search_fields=["title", "artist__name"]) q = fields.SearchFilter(
search_fields=["title", "artist_credit__artist__name"],
fts_search_fields=["body_text", "artist_credit__artist__body_text"],
)
content_category = filters.CharFilter("artist_credit__artist__content_category")
tag = TAG_FILTER tag = TAG_FILTER
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor", distinct=True actor_field="tracks__uploads__library__actor",
distinct=True,
library_field="tracks__uploads__library",
)
ordering = django_filters.OrderingFilter(
fields=(
("creation_date", "creation_date"),
("release_date", "release_date"),
("title", "title"),
(
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"),
("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"
)
artist = filters.ModelChoiceFilter(
field_name="_", method="filter_artist", queryset=models.Artist.objects.all()
) )
class Meta: class Meta:
model = models.Album model = models.Album
fields = ["playable", "q", "artist", "scope", "mbid"] fields = ["artist_credit", "mbid"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
include_channels_field = "artist__channel" include_channels_field = "artist_credit__artist__channel"
channel_filter_field = "track__album"
library_filter_field = "track__album"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value) 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))
def filter_artist(self, queryset, name, value):
return queryset.filter(artist_credit__artist=value)
class LibraryFilter(filters.FilterSet):
q = fields.SearchFilter(
search_fields=["name"],
)
scope = common_filters.ActorScopeFilter(
actor_field="actor",
distinct=True,
library_field="pk",
)
class Meta:
model = models.Library
fields = ["privacy_level"]
...@@ -6,23 +6,26 @@ def load(model, *args, **kwargs): ...@@ -6,23 +6,26 @@ def load(model, *args, **kwargs):
EXCLUDE_VALIDATION = {"Track": ["artist"]} EXCLUDE_VALIDATION = {"Track": ["artist"]}
class Importer(object): class Importer:
def __init__(self, model): def __init__(self, model):
self.model = model self.model = model
def load(self, cleaned_data, raw_data, import_hooks): def load(self, cleaned_data, raw_data, import_hooks):
mbid = cleaned_data.pop("mbid") mbid = cleaned_data.pop("mbid")
artists_credits = cleaned_data.pop("artist_credit", None)
# let's validate data, just in case # let's validate data, just in case
instance = self.model(**cleaned_data) instance = self.model(**cleaned_data)
exclude = EXCLUDE_VALIDATION.get(self.model.__name__, []) exclude = EXCLUDE_VALIDATION.get(self.model.__name__, [])
instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude) instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude)
m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0] m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0]
if artists_credits:
m.artist_credit.set(artists_credits)
for hook in import_hooks: for hook in import_hooks:
hook(m, cleaned_data, raw_data) hook(m, cleaned_data, raw_data)
return m return m
class Mapping(object): class Mapping:
"""Cast musicbrainz data to funkwhale data and vice-versa""" """Cast musicbrainz data to funkwhale data and vice-versa"""
def __init__(self, musicbrainz_mapping): def __init__(self, musicbrainz_mapping):
...@@ -47,4 +50,9 @@ class Mapping(object): ...@@ -47,4 +50,9 @@ class Mapping(object):
) )
registry = {"Artist": Importer, "Track": Importer, "Album": Importer} registry = {
"Artist": Importer,
"ArtistCredit": Importer,
"Track": Importer,
"Album": Importer,
}
...@@ -28,23 +28,23 @@ def load(data): ...@@ -28,23 +28,23 @@ def load(data):
for row in data: for row in data:
try: try:
license = existing_by_code[row["code"]] license_ = existing_by_code[row["code"]]
except KeyError: except KeyError:
logger.info("Loading new license: {}".format(row["code"])) logger.debug("Loading new license: {}".format(row["code"]))
to_create.append( to_create.append(
models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS}) models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
) )
else: else:
logger.info("Updating license: {}".format(row["code"])) logger.debug("Updating license: {}".format(row["code"]))
stored = [getattr(license, f) for f in MODEL_FIELDS] stored = [getattr(license_, f) for f in MODEL_FIELDS]
wanted = [row[f] for f in MODEL_FIELDS] wanted = [row[f] for f in MODEL_FIELDS]
if wanted == stored: if wanted == stored:
continue continue
# the object in database needs an update # the object in database needs an update
for f in MODEL_FIELDS: for f in MODEL_FIELDS:
setattr(license, f, row[f]) setattr(license_, f, row[f])
license.save() license_.save()
models.License.objects.bulk_create(to_create) models.License.objects.bulk_create(to_create)
return sorted(models.License.objects.all(), key=lambda o: o.code) return sorted(models.License.objects.all(), key=lambda o: o.code)
...@@ -70,7 +70,7 @@ def match(*values): ...@@ -70,7 +70,7 @@ def match(*values):
value, value,
) )
if not urls: if not urls:
logger.debug('Impossible to guess license from string "{}"'.format(value)) logger.debug(f'Impossible to guess license from string "{value}"')
continue continue
url = urls[0] url = urls[0]
if _cache: if _cache:
...@@ -78,12 +78,12 @@ def match(*values): ...@@ -78,12 +78,12 @@ def match(*values):
else: else:
existing = load(LICENSES) existing = load(LICENSES)
_cache = existing _cache = existing
for license in existing: for license_ in existing:
if license.conf is None: if license_.conf is None:
continue continue
for i in license.conf["identifiers"]: for i in license_.conf["identifiers"]:
if match_urls(url, i): if match_urls(url, i):
return license return license_
def match_urls(*urls): def match_urls(*urls):
...@@ -122,7 +122,7 @@ def get_cc_license(version, perks, country=None, country_name=None): ...@@ -122,7 +122,7 @@ def get_cc_license(version, perks, country=None, country_name=None):
) )
if country: if country:
code_parts.append(country) code_parts.append(country)
name += " {}".format(country_name) name += f" {country_name}"
url += country + "/" url += country + "/"
data = { data = {
"name": name, "name": name,
...@@ -277,6 +277,17 @@ LICENSES = [ ...@@ -277,6 +277,17 @@ LICENSES = [
"http://creativecommons.org/publicdomain/zero/1.0/" "http://creativecommons.org/publicdomain/zero/1.0/"
], ],
}, },
{
"code": "LAL-1.3",
"name": "Licence Art Libre 1.3",
"redistribute": True,
"derivative": True,
"commercial": True,
"attribution": True,
"copyleft": True,
"url": "https://artlibre.org/licence/lal",
"identifiers": ["http://artlibre.org/licence/lal"],
},
# Creative commons version 4.0 # Creative commons version 4.0
get_cc_license(version="4.0", perks=["by"]), get_cc_license(version="4.0", perks=["by"]),
get_cc_license(version="4.0", perks=["by", "sa"]), get_cc_license(version="4.0", perks=["by", "sa"]),
......
...@@ -2,7 +2,6 @@ import os ...@@ -2,7 +2,6 @@ import os
from argparse import RawTextHelpFormatter from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from funkwhale_api.music import models from funkwhale_api.music import models
...@@ -14,7 +13,7 @@ def progress(buffer, count, total, status=""): ...@@ -14,7 +13,7 @@ def progress(buffer, count, total, status=""):
bar = "=" * filled_len + "-" * (bar_len - filled_len) bar = "=" * filled_len + "-" * (bar_len - filled_len)
buffer.write("[%s] %s/%s ...%s\r" % (bar, count, total, status)) buffer.write(f"[{bar}] {count}/{total} ...{status}\r")
buffer.flush() buffer.flush()
...@@ -44,10 +43,10 @@ class Command(BaseCommand): ...@@ -44,10 +43,10 @@ class Command(BaseCommand):
candidates = models.Upload.objects.filter(source__startswith="file://") candidates = models.Upload.objects.filter(source__startswith="file://")
candidates = candidates.filter(audio_file__in=["", None]) candidates = candidates.filter(audio_file__in=["", None])
total = candidates.count() total = candidates.count()
self.stdout.write("Checking {} in-place imported files…".format(total)) self.stdout.write(f"Checking {total} in-place imported files…")
missing = [] missing = []
for i, row in enumerate(candidates.values("id", "source")): for i, row in enumerate(candidates.values("id", "source").iterator()):
path = row["source"].replace("file://", "") path = row["source"].replace("file://", "")
progress(self.stdout, i + 1, total) progress(self.stdout, i + 1, total)
if not os.path.exists(path): if not os.path.exists(path):
...@@ -55,7 +54,7 @@ class Command(BaseCommand): ...@@ -55,7 +54,7 @@ class Command(BaseCommand):
if missing: if missing:
for path, _ in missing: for path, _ in missing:
self.stdout.write(" {}".format(path)) self.stdout.write(f" {path}")
self.stdout.write( self.stdout.write(
"The previous {} paths are referenced in database, but not found on disk!".format( "The previous {} paths are referenced in database, but not found on disk!".format(
len(missing) len(missing)
...@@ -72,5 +71,5 @@ class Command(BaseCommand): ...@@ -72,5 +71,5 @@ class Command(BaseCommand):
"Nothing was deleted, rerun this command with --no-dry-run to apply the changes" "Nothing was deleted, rerun this command with --no-dry-run to apply the changes"
) )
else: else:
self.stdout.write("Deleting {} uploads…".format(to_delete.count())) self.stdout.write(f"Deleting {to_delete.count()} uploads…")
to_delete.delete() to_delete.delete()
from django.core.management.base import BaseCommand, CommandError
import requests.exceptions import requests.exceptions
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.music import licenses from funkwhale_api.music import licenses
...@@ -21,7 +21,7 @@ class Command(BaseCommand): ...@@ -21,7 +21,7 @@ class Command(BaseCommand):
errored.append((data, response)) errored.append((data, response))
if errored: if errored:
self.stdout.write("{} licenses were not reachable!".format(len(errored))) self.stdout.write(f"{len(errored)} licenses were not reachable!")
for row, response in errored: for row, response in errored:
self.stdout.write( self.stdout.write(
"- {}: error {} at url {}".format( "- {}: error {} at url {}".format(
......