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
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2467-fix-radio-builder
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2480-add-notification-number-badges
  • 2482-upgrade-about-page-to-use-new-ui
  • 2487-fix-accessibility-according-to-WCAG
  • 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
  • 2550-22-user-interfaces-for-federation
  • 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-channel-creation
  • 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
  • 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
  • 1.0.1
  • 1108-remove-jwt-and-switch-to-oauth-for-ui-auth
  • 1121-download
  • 1278-embed-isn-t-available-in-the-front-end-for-channel-tracks
  • 1299-encoding-problem-in-rss-feeds
  • 1303-failing-to-refetch-federated-tracks
  • 1311-feedparser-requires-update-to-accomodate-python-3-9
  • 1346-selectoreventloop-required-instead-got-uvloop-loop
  • 1356-update-packages
  • develop
  • master
  • plugins
  • plugins-v2
  • plugins-v3
  • set-sast-config-1
  • set-sast-config-2
  • tracemallocmiddleware
  • 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
79 results
Show changes
Showing
with 927 additions and 204 deletions
import urllib.parse
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.common import preferences, utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.moderation import mrf
......@@ -30,16 +29,13 @@ def check_allow_list(payload, **kwargs):
utils.recursive_getattr(payload, "object.id", permissive=True),
]
relevant_domains = set(
[
relevant_domains = {
domain
for domain in [urllib.parse.urlparse(i).hostname for i in relevant_ids if i]
if domain
]
)
}
if relevant_domains - allowed_domains:
raise mrf.Discard(
"These domains are not allowed: {}".format(
", ".join(relevant_domains - allowed_domains)
......
import json
import urllib.parse
import persisting_theory
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
import persisting_theory
from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
......@@ -14,8 +14,7 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from . import models
from . import tasks
from . import models, tasks
class FilteredArtistSerializer(serializers.ModelSerializer):
......@@ -24,7 +23,7 @@ class FilteredArtistSerializer(serializers.ModelSerializer):
fields = ["id", "name"]
class TargetSerializer(serializers.Serializer):
class ModerationTargetSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["artist"])
id = serializers.CharField()
......@@ -44,7 +43,7 @@ class TargetSerializer(serializers.Serializer):
class UserFilterSerializer(serializers.ModelSerializer):
target = TargetSerializer()
target = ModerationTargetSerializer()
class Meta:
model = models.UserFilter
......@@ -62,7 +61,7 @@ class UserFilterSerializer(serializers.ModelSerializer):
state_serializers = persisting_theory.Registry()
class DescriptionStateMixin(object):
class DescriptionStateMixin:
def get_description(self, o):
if o.description:
return o.description.text
......@@ -90,10 +89,29 @@ class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
]
@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")
class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD
artist = ArtistStateSerializer()
artist_credit = ArtistCreditStateSerializer(many=True)
class Meta:
model = music_models.Album
......@@ -104,7 +122,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid",
"creation_date",
"uuid",
"artist",
"artist_credit",
"release_date",
"tags",
"description",
......@@ -114,7 +132,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
@state_serializers.register(name="music.Track")
class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD
artist = ArtistStateSerializer()
artist_credit = ArtistCreditStateSerializer(many=True)
album = AlbumStateSerializer()
class Meta:
......@@ -126,7 +144,7 @@ class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid",
"creation_date",
"uuid",
"artist",
"artist_credit",
"album",
"disc_number",
"position",
......@@ -146,7 +164,6 @@ class LibraryStateSerializer(serializers.ModelSerializer):
"uuid",
"fid",
"name",
"description",
"creation_date",
"privacy_level",
]
......@@ -217,7 +234,7 @@ def get_target_owner(target):
music_models.Album: lambda t: t.attributed_to,
music_models.Track: lambda t: t.attributed_to,
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,
}
......@@ -231,6 +248,7 @@ TARGET_CONFIG = {
"id_field": serializers.UUIDField(),
},
"artist": {"queryset": music_models.Artist.objects.all()},
"artist_credit": {"queryset": music_models.ArtistCredit.objects.all()},
"album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()},
"library": {
......@@ -304,7 +322,7 @@ class ReportSerializer(serializers.ModelSerializer):
if not validated_data.get("submitter_email"):
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
......
import django.dispatch
report_created = django.dispatch.Signal(providing_args=["report"])
""" Required argument: report """
report_created = django.dispatch.Signal()
import logging
from django.core import mail
from django.conf import settings
from django.core import mail
from django.db import transaction
from django.dispatch import receiver
from funkwhale_api.common import channels
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.taskapp import celery
from funkwhale_api.common import channels, preferences, 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 . import models
from . import signals
from . import models, signals
logger = logging.getLogger(__name__)
......@@ -66,9 +64,7 @@ def send_new_report_email_to_moderators(report):
subject = "[{} moderation - {}] New report from {}".format(
settings.FUNKWHALE_HOSTNAME, report.get_type_display(), submitter_repr
)
detail_url = federation_utils.full_url(
"/manage/moderation/reports/{}".format(report.uuid)
)
detail_url = federation_utils.full_url(f"/manage/moderation/reports/{report.uuid}")
unresolved_reports_url = federation_utils.full_url(
"/manage/moderation/reports?q=resolved:no"
)
......@@ -99,21 +95,23 @@ def send_new_report_email_to_moderators(report):
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(
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
),
]
for moderator in moderators:
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
mail.send_mail(
subject,
......@@ -173,9 +171,7 @@ def notify_mods_signup_request_pending(obj):
subject = "[{} moderation] New sign-up request from {}".format(
settings.FUNKWHALE_HOSTNAME, submitter_repr
)
detail_url = federation_utils.full_url(
"/manage/moderation/requests/{}".format(obj.uuid)
)
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"
)
......@@ -185,21 +181,23 @@ def notify_mods_signup_request_pending(obj):
submitter_repr
),
"",
"- To handle this request, please visit {}".format(detail_url),
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 email because you are a moderator for {}.".format(
"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 email configured", moderator.username)
logger.warning(
"Moderator %s has no e-mail address configured", moderator.username
)
continue
mail.send_mail(
subject,
......@@ -213,17 +211,17 @@ 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 email configured", submitter_repr)
logger.warning("User %s has no e-mail address configured", submitter_repr)
return
subject = "Welcome to {}, {}!".format(settings.FUNKWHALE_HOSTNAME, submitter_repr)
subject = f"Welcome to {settings.FUNKWHALE_HOSTNAME}, {submitter_repr}!"
login_url = federation_utils.full_url("/login")
body = [
"Hi {} and welcome,".format(submitter_repr),
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 email address if you didn't already.",
"Before your first login, you may need to verify your e-mail address if you didn't already.",
]
mail.send_mail(
......@@ -238,13 +236,13 @@ 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 email configured", submitter_repr)
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 = [
"Hi {},".format(submitter_repr),
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 "
......
......@@ -5,7 +5,6 @@ from funkwhale_api.federation import models as federation_models
from . import models
from . import serializers as moderation_serializers
NOTE_TARGET_FIELDS = {
"report": {
"queryset": models.Report.objects.all(),
......
from django.db import IntegrityError
from rest_framework import mixins
from rest_framework import response
from rest_framework import status
from rest_framework import viewsets
from rest_framework import mixins, response, status, viewsets
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from . import models
from . import serializers
from . import models, serializers
class UserFilterViewSet(
......
......@@ -3,6 +3,17 @@ from funkwhale_api.common import admin
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)
class ArtistAdmin(admin.ModelAdmin):
list_display = ["name", "mbid", "creation_date", "modification_date"]
......@@ -11,16 +22,35 @@ class ArtistAdmin(admin.ModelAdmin):
@admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "mbid", "release_date", "creation_date"]
search_fields = ["title", "artist__name", "mbid"]
list_display = ["title", "mbid", "release_date", "creation_date"]
search_fields = ["title", "mbid"]
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)
class TrackAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "album", "mbid"]
search_fields = ["title", "artist__name", "album__title", "mbid"]
list_select_related = ["album__artist", "artist"]
list_display = ["title", "album", "mbid", "artist"]
search_fields = ["title", "album__title", "mbid"]
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)
......@@ -57,6 +87,7 @@ class UploadAdmin(admin.ModelAdmin):
"size",
"bitrate",
"import_status",
"library",
]
list_select_related = ["track"]
search_fields = [
......@@ -68,6 +99,14 @@ class UploadAdmin(admin.ModelAdmin):
]
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)
class UploadVersionAdmin(admin.ModelAdmin):
......@@ -103,7 +142,7 @@ launch_scan.short_description = "Launch scan"
class LibraryAdmin(admin.ModelAdmin):
list_display = ["id", "name", "actor", "uuid", "privacy_level", "creation_date"]
list_select_related = True
search_fields = ["actor__username", "name", "description"]
search_fields = ["uuid", "name", "actor__preferred_username"]
list_filter = ["privacy_level"]
actions = [launch_scan]
......
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
music = types.Section("music")
quality_filters = types.Section("quality_filters")
@global_preferences_registry.register
......@@ -32,3 +34,171 @@ class MusicCacheDuration(types.IntPreference):
"will be erased and retranscoded on the next listening."
)
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
from urllib.parse import urlparse
import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate
from django.conf import settings
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.music import licenses
from funkwhale_api.tags import factories as tags_factories
......@@ -63,7 +64,7 @@ class ArtistFactory(
name = factory.Faker("name")
mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist")
playable = playable_factory("track__album__artist_credit__artist")
class Meta:
model = "music.Artist"
......@@ -78,6 +79,21 @@ class ArtistFactory(
)
@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
class AlbumFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
......@@ -85,7 +101,6 @@ class AlbumFactory(
title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4")
release_date = factory.Faker("date_object")
artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
playable = playable_factory("track__album")
......@@ -97,19 +112,28 @@ class AlbumFactory(
attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
)
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
class TrackFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
):
uuid = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4")
......@@ -126,34 +150,48 @@ class TrackFactory(
)
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
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
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)
"""
# 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:
self.artist = extracted
self.artist_credit.add(extracted)
elif kwargs:
if created:
self.artist = ArtistFactory(**kwargs)
self.artist_credit.add(ArtistCreditFactory(**kwargs))
else:
self.artist = ArtistFactory.build(**kwargs)
self.artist_credit.add(ArtistCreditFactory.build(**kwargs))
elif self.album:
self.artist = self.album.artist
self.artist_credit.set(self.album.artist_credit.all())
if created:
self.save()
@factory.post_generation
def license(self, created, extracted, **kwargs):
# The @factory.post_generation is not used because we must
# not redefine the builtin `license` function.
def _license_post_generation(self, created, extracted, **kwargs):
if not created:
return
......@@ -161,6 +199,8 @@ class TrackFactory(
self.license = LicenseFactory(code=extracted)
self.save()
license = factory.PostGeneration(_license_post_generation)
@registry.register
class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
......@@ -171,10 +211,11 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
from_path=os.path.join(SAMPLES_PATH, "test.ogg")
)
bitrate = None
size = None
duration = None
bitrate = 320
size = 320
duration = 320
mimetype = "audio/ogg"
quality = 1
class Meta:
model = "music.Upload"
......@@ -184,6 +225,11 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
playable = factory.Trait(
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):
......@@ -192,7 +238,9 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
from funkwhale_api.audio import factories as audio_factories
audio_factories.ChannelFactory(
library=self.library, artist=self.track.artist, **kwargs
library=self.library,
artist=self.track.artist_credit.all()[0].artist,
**kwargs
)
......
"""
Populates the database with fake data
"""
import logging
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):
artists = factories.ArtistFactory.create_batch(size=count)
for artist in artists:
print("Creating data for", artist)
albums = factories.AlbumFactory.create_batch(
artist=artist, size=random.randint(1, 5)
# my podcast
my_podcast_library = federation_factories.MusicLibraryFactory(
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
local=True,
)
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:
factories.UploadFactory.create_batch(
track__album=album, size=random.randint(3, 18)
music_factories.TrackFactory.create_batch(
size=random.randint(3, 6),
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
)
......
from django.db.models import Q
import django_filters
from django.db.models import Q
from django_filters import rest_framework as filters
from funkwhale_api.audio import filters as audio_filters
......@@ -11,14 +10,13 @@ from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.tags import filters as tags_filters
from . import models
from . import utils
from . import models, utils
def filter_tags(queryset, name, value):
non_empty_tags = [v.lower() for v in value if v]
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
......@@ -48,7 +46,6 @@ class RelatedFilterSet(filters.FilterSet):
class ChannelFilterSet(filters.FilterSet):
channel = filters.CharFilter(field_name="_", method="filter_channel")
def filter_channel(self, queryset, name, value):
......@@ -72,7 +69,6 @@ class ChannelFilterSet(filters.FilterSet):
class LibraryFilterSet(filters.FilterSet):
library = filters.CharFilter(field_name="_", method="filter_library")
def filter_library(self, queryset, name, value):
......@@ -98,18 +94,17 @@ class ArtistFilter(
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
has_albums = filters.BooleanFilter(field_name="_", method="filter_has_albums")
tag = TAG_FILTER
content_category = filters.CharFilter("content_category")
scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor",
actor_field="artist_credit__tracks__uploads__library__actor",
distinct=True,
library_field="tracks__uploads__library",
library_field="artist_credit__tracks__uploads__library",
)
ordering = django_filters.OrderingFilter(
ordering = common_filters.CaseInsensitiveNameOrderingFilter(
fields=(
("id", "id"),
("name", "name"),
......@@ -120,6 +115,11 @@ class ArtistFilter(
)
)
has_mbid = filters.BooleanFilter(
field_name="_",
method="filter_has_mbid",
)
class Meta:
model = models.Artist
fields = {
......@@ -128,17 +128,17 @@ class ArtistFilter(
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel"
library_filter_field = "track__artist"
library_filter_field = "track__artist_credit__artist"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct()
def filter_has_albums(self, queryset, name, value):
if value is True:
return queryset.filter(albums__isnull=False)
else:
return queryset.filter(albums__isnull=True)
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(
......@@ -149,8 +149,16 @@ class TrackFilter(
moderation_filters.HiddenContentFilterSet,
):
q = fields.SearchFilter(
search_fields=["title", "album__title", "artist__name"],
fts_search_fields=["body_text", "artist__body_text", "album__body_text"],
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")
tag = TAG_FILTER
......@@ -173,12 +181,30 @@ class TrackFilter(
("size", "size"),
("position", "position"),
("disc_number", "disc_number"),
("artist__name", "artist__name"),
("artist__modification_date", "artist__modification_date"),
("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:
model = models.Track
......@@ -190,30 +216,52 @@ class TrackFilter(
"mbid": ["exact"],
}
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):
actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct()
def filter_artist(self, queryset, name, value):
return queryset.filter(Q(artist=value) | Q(album__artist=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):
library = filters.CharFilter("library__uuid")
channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid")
track_artist = filters.UUIDFilter("track__artist_credit__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist_credit__artist__uuid")
library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter(
actor_field="library__actor", distinct=True, library_field="library",
actor_field="library__actor",
distinct=True,
library_field="library",
)
import_status = common_filters.MultipleQueryFilter(coerce=str)
import_status = common_filters.MultipleQueryFilter(coerce=str, distinct=False)
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
......@@ -239,7 +287,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
"mimetype",
"import_reference",
]
include_channels_field = "track__artist__channel"
include_channels_field = "track__artist_credit__artist__channel"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......@@ -255,10 +303,10 @@ class AlbumFilter(
):
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(
search_fields=["title", "artist__name"],
fts_search_fields=["body_text", "artist__body_text"],
search_fields=["title", "artist_credit__artist__name"],
fts_search_fields=["body_text", "artist_credit__artist__body_text"],
)
content_category = filters.CharFilter("artist__content_category")
content_category = filters.CharFilter("artist_credit__artist__content_category")
tag = TAG_FILTER
scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor",
......@@ -271,17 +319,42 @@ class AlbumFilter(
("creation_date", "creation_date"),
("release_date", "release_date"),
("title", "title"),
("artist__modification_date", "artist__modification_date"),
(
"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:
model = models.Album
fields = ["artist", "mbid"]
fields = ["artist_credit", "mbid"]
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"
......@@ -289,13 +362,43 @@ 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))
def filter_artist(self, queryset, name, value):
return queryset.filter(artist_credit__artist=value)
class LibraryFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=["name"],)
q = fields.SearchFilter(
search_fields=["name"],
)
scope = common_filters.ActorScopeFilter(
actor_field="actor", distinct=True, library_field="pk",
actor_field="actor",
distinct=True,
library_field="pk",
)
actor = filters.CharFilter(method="filter_actor")
class Meta:
model = models.Library
fields = ["privacy_level"]
def filter_actor(self, queryset, name, value):
# supports username or username@domain
if "@" in value:
username, domain = value.split("@", 1)
return queryset.filter(
actor__preferred_username=username,
actor__domain_id=domain,
)
return queryset.filter(actor__preferred_username=value)
......@@ -6,23 +6,26 @@ def load(model, *args, **kwargs):
EXCLUDE_VALIDATION = {"Track": ["artist"]}
class Importer(object):
class Importer:
def __init__(self, model):
self.model = model
def load(self, cleaned_data, raw_data, import_hooks):
mbid = cleaned_data.pop("mbid")
artists_credits = cleaned_data.pop("artist_credit", None)
# let's validate data, just in case
instance = self.model(**cleaned_data)
exclude = EXCLUDE_VALIDATION.get(self.model.__name__, [])
instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude)
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:
hook(m, cleaned_data, raw_data)
return m
class Mapping(object):
class Mapping:
"""Cast musicbrainz data to funkwhale data and vice-versa"""
def __init__(self, musicbrainz_mapping):
......@@ -47,4 +50,9 @@ class Mapping(object):
)
registry = {"Artist": Importer, "Track": Importer, "Album": Importer}
registry = {
"Artist": Importer,
"ArtistCredit": Importer,
"Track": Importer,
"Album": Importer,
}
......@@ -28,7 +28,7 @@ def load(data):
for row in data:
try:
license = existing_by_code[row["code"]]
license_ = existing_by_code[row["code"]]
except KeyError:
logger.debug("Loading new license: {}".format(row["code"]))
to_create.append(
......@@ -36,15 +36,15 @@ def load(data):
)
else:
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]
if wanted == stored:
continue
# the object in database needs an update
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)
return sorted(models.License.objects.all(), key=lambda o: o.code)
......@@ -70,7 +70,7 @@ def match(*values):
value,
)
if not urls:
logger.debug('Impossible to guess license from string "{}"'.format(value))
logger.debug(f'Impossible to guess license from string "{value}"')
continue
url = urls[0]
if _cache:
......@@ -78,12 +78,12 @@ def match(*values):
else:
existing = load(LICENSES)
_cache = existing
for license in existing:
if license.conf is None:
for license_ in existing:
if license_.conf is None:
continue
for i in license.conf["identifiers"]:
for i in license_.conf["identifiers"]:
if match_urls(url, i):
return license
return license_
def match_urls(*urls):
......@@ -122,7 +122,7 @@ def get_cc_license(version, perks, country=None, country_name=None):
)
if country:
code_parts.append(country)
name += " {}".format(country_name)
name += f" {country_name}"
url += country + "/"
data = {
"name": name,
......
......@@ -2,7 +2,6 @@ import os
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.db import transaction
from funkwhale_api.music import models
......@@ -14,7 +13,7 @@ def progress(buffer, count, total, status=""):
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()
......@@ -44,7 +43,7 @@ class Command(BaseCommand):
candidates = models.Upload.objects.filter(source__startswith="file://")
candidates = candidates.filter(audio_file__in=["", None])
total = candidates.count()
self.stdout.write("Checking {} in-place imported files…".format(total))
self.stdout.write(f"Checking {total} in-place imported files…")
missing = []
for i, row in enumerate(candidates.values("id", "source").iterator()):
......@@ -55,7 +54,7 @@ class Command(BaseCommand):
if missing:
for path, _ in missing:
self.stdout.write(" {}".format(path))
self.stdout.write(f" {path}")
self.stdout.write(
"The previous {} paths are referenced in database, but not found on disk!".format(
len(missing)
......@@ -72,5 +71,5 @@ class Command(BaseCommand):
"Nothing was deleted, rerun this command with --no-dry-run to apply the changes"
)
else:
self.stdout.write("Deleting {} uploads…".format(to_delete.count()))
self.stdout.write(f"Deleting {to_delete.count()} uploads…")
to_delete.delete()
from django.core.management.base import BaseCommand, CommandError
import requests.exceptions
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.music import licenses
......@@ -21,7 +21,7 @@ class Command(BaseCommand):
errored.append((data, response))
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:
self.stdout.write(
"- {}: error {} at url {}".format(
......
from django.core.management.base import BaseCommand
from funkwhale_api.federation.models import Actor
from funkwhale_api.music.models import Library
class Command(BaseCommand):
help = """
Create a new library for a given user.
"""
def add_arguments(self, parser):
parser.add_argument(
"username",
type=str,
help=("Specify the owner of the library to be created."),
)
parser.add_argument(
"--name",
type=str,
help=("Specify a name for the library."),
default="default",
)
parser.add_argument(
"--privacy-level",
type=str.lower,
choices=["me", "instance", "everyone"],
help=("Specify the privacy level for the library."),
default="me",
)
def handle(self, *args, **kwargs):
actor, actor_created = Actor.objects.get_or_create(name=kwargs["username"])
if actor_created:
self.stdout.write("No existing actor found. New actor created.")
library, created = Library.objects.get_or_create(
name=kwargs["name"], actor=actor, privacy_level=kwargs["privacy_level"]
)
if created:
self.stdout.write(
"Created library {} for user {} with UUID {}".format(
library.pk, actor.user.pk, library.uuid
)
)
else:
self.stdout.write(
"Found existing library {} for user {} with UUID {}".format(
library.pk, actor.user.pk, library.uuid
)
)
import os
import mutagen
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from funkwhale_api.music import models, utils
from funkwhale_api.playlists import models as playlist_models
from funkwhale_api.users import models as user_models
def get_or_create_playlist(self, playlist_name, user, **options):
playlist = playlist_models.Playlist.objects.filter(
Q(actor=user.actor) & Q(name=playlist_name)
).first()
if not playlist:
if options["no_dry_run"]:
playlist = playlist_models.Playlist.objects.create(
name=playlist_name,
actor=user.actor,
privacy_level=options["privacy_level"],
)
return playlist
response = input(
f"This playlist {playlist_name} will be created. Proceed? (y/n): "
)
if response.lower() in "yes":
playlist = playlist_models.Playlist.objects.create(
name=playlist_name,
actor=user.actor,
privacy_level=options["privacy_level"],
)
return playlist
else:
return playlist
def get_fw_track_list(self, directory, playlist, **options):
fw_tracks = []
audio_extensions = utils.SUPPORTED_EXTENSIONS
existing_tracks = playlist.playlist_tracks.select_for_update()
for file in next(os.walk(directory))[2]:
if file.endswith(tuple(audio_extensions)):
track_path = os.path.join(directory, file)
try:
audio = mutagen.File(track_path)
except mutagen.MutagenError as e:
self.stdout.write(
f"Could not load {track_path} because of a mutagen exception : {e}"
)
if options["only_mbid"]:
mbid = (
audio.get("UFID:http://musicbrainz.org", None).data.decode()
if audio.get("UFID:http://musicbrainz.org", None)
else None
)
if not mbid:
self.stdout.write(
f"Did not find mbid, skipping track {track_path}..."
)
continue
try:
track_fw = models.Track.objects.get(mbid=mbid)
except models.Track.DoesNotExist:
self.stdout.write(f"No track found for {track_path}")
continue
else:
try:
self.stdout.write(f"rack_path {str(track_path)}...")
track_fw = models.Upload.objects.get(source=track_path)
except models.Upload.DoesNotExist:
self.stdout.write(f"No track found for {track_path}")
continue
if existing_tracks.filter(track__id=track_fw.id).exists():
self.stdout.write(
f"Track already in playlist. Skipping {track_path}..."
)
continue
fw_tracks.append(track_fw)
return fw_tracks
def add_tracks_to_playlist(self, directory, user, **options):
playlist_name = os.path.basename(directory)
playlist = get_or_create_playlist(self, playlist_name, user, **options)
fw_track_list = get_fw_track_list(self, directory, playlist, **options)
if options["no_dry_run"] is True:
return playlist.insert_many(fw_track_list, allow_duplicates=False)
response = input(
f"These tracks {fw_track_list} will be added to playlist {playlist_name}. Proceed? (y/n): "
)
if response.lower() in "yes":
return playlist.insert_many(fw_track_list, allow_duplicates=False)
class Command(BaseCommand):
help = """
This command creates playlists based on a folder structure. It uses the base folder
of each track as the playlist name. Subdirectories are taken into account but generate independent
playlists. Tracks contained in subdirectories don't appear in the parent directory playlist.
You will be asked to confirm the action before the playlist is created. Duplicate content in the
playlist isn't supported.
"""
def add_arguments(self, parser):
parser.add_argument(
"--user_name",
help="User name that will own the playlists",
)
parser.add_argument(
"--dir_name",
help="Which directory to start from.",
)
parser.add_argument(
"--privacy_level",
default="me",
choices=["me", "instance", "everyone"],
help="Which privacy_level for the playlists.",
)
parser.add_argument(
"--no_dry_run",
default=False,
help="Will actually write data into the database",
)
parser.add_argument(
"--only_mbid",
default=False,
help='Only files tagged with mbid will be used. Can be useful to create playlist from folders \
that are not "in-place" imported into funkwhale',
)
@transaction.atomic
def handle(self, *args, **options):
all_subdirectories = []
for root, dirs, files in os.walk(options["dir_name"]):
for dir_name in dirs:
full_dir_path = os.path.join(root, dir_name)
all_subdirectories.append(full_dir_path)
user = user_models.User.objects.get(username=options["user_name"])
for directory in all_subdirectories:
add_tracks_to_playlist(self, directory, user, **options)
......@@ -73,20 +73,18 @@ class Command(BaseCommand):
Q(source__startswith="file://") | Q(source__startswith="upload://")
).exclude(mimetype__startswith="audio/")
total = matching.count()
self.stdout.write(
"[mimetypes] {} entries found with bad or no mimetype".format(total)
)
self.stdout.write(f"[mimetypes] {total} entries found with bad or no mimetype")
if not total:
return
for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items():
qs = matching.filter(source__endswith=".{}".format(extension))
qs = matching.filter(source__endswith=f".{extension}")
self.stdout.write(
"[mimetypes] setting {} {} files to {}".format(
qs.count(), extension, mimetype
)
)
if not dry_run:
self.stdout.write("[mimetypes] commiting...")
self.stdout.write("[mimetypes] committing...")
qs.update(mimetype=mimetype)
def fix_file_data(self, dry_run, **kwargs):
......@@ -95,9 +93,7 @@ class Command(BaseCommand):
Q(bitrate__isnull=True) | Q(duration__isnull=True)
)
total = matching.count()
self.stdout.write(
"[bitrate/length] {} entries found with missing values".format(total)
)
self.stdout.write(f"[bitrate/length] {total} entries found with missing values")
if dry_run:
return
......@@ -135,7 +131,7 @@ class Command(BaseCommand):
self.stdout.write("Fixing missing size...")
matching = models.Upload.objects.filter(size__isnull=True)
total = matching.count()
self.stdout.write("[size] {} entries found with missing values".format(total))
self.stdout.write(f"[size] {total} entries found with missing values")
if dry_run:
return
......@@ -148,16 +144,12 @@ class Command(BaseCommand):
for upload in chunk:
handled += 1
self.stdout.write(
"[size] {}/{} fixing file #{}".format(handled, total, upload.pk)
)
self.stdout.write(f"[size] {handled}/{total} fixing file #{upload.pk}")
try:
upload.size = upload.get_file_size()
except Exception as e:
self.stderr.write(
"[size] error with file #{}: {}".format(upload.pk, str(e))
)
self.stderr.write(f"[size] error with file #{upload.pk}: {str(e)}")
else:
updated.append(upload)
......@@ -170,9 +162,7 @@ class Command(BaseCommand):
& (Q(audio_file__isnull=False) | Q(source__startswith="file://"))
)
total = matching.count()
self.stdout.write(
"[checksum] {} entries found with missing values".format(total)
)
self.stdout.write(f"[checksum] {total} entries found with missing values")
if dry_run:
return
chunks = common_utils.chunk_queryset(
......@@ -184,7 +174,7 @@ class Command(BaseCommand):
for upload in chunk:
handled += 1
self.stdout.write(
"[checksum] {}/{} fixing file #{}".format(handled, total, upload.pk)
f"[checksum] {handled}/{total} fixing file #{upload.pk}"
)
try:
......@@ -193,7 +183,7 @@ class Command(BaseCommand):
)
except Exception as e:
self.stderr.write(
"[checksum] error with file #{}: {}".format(upload.pk, str(e))
f"[checksum] error with file #{upload.pk}: {str(e)}"
)
else:
updated.append(upload)
......
from django.core.management.base import BaseCommand
from funkwhale_api.typesense import tasks
class Command(BaseCommand):
help = """
Trigger the generation of a new typesense index for canonical Funkwhale tracks metadata.
This is use to resolve Funkwhale tracks to MusicBrainz ids"""
def handle(self, *args, **kwargs):
tasks.build_canonical_index.delay()
self.stdout.write("Tasks launched in celery worker.")
......@@ -10,7 +10,6 @@ import urllib.parse
import watchdog.events
import watchdog.observers
from django.conf import settings
from django.core.cache import cache
from django.core.files import File
......@@ -19,7 +18,6 @@ from django.core.management.base import BaseCommand, CommandError
from django.db.models import Q
from django.db.utils import IntegrityError
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import utils as common_utils
......@@ -33,7 +31,7 @@ def crawl_dir(dir, extensions, recursive=True, ignored=[]):
try:
scanner = os.scandir(dir)
except Exception as e:
m = "Error while reading {}: {} {}\n".format(dir, e.__class__.__name__, e)
m = f"Error while reading {dir}: {e.__class__.__name__} {e}\n"
sys.stderr.write(m)
return
try:
......@@ -41,7 +39,7 @@ def crawl_dir(dir, extensions, recursive=True, ignored=[]):
try:
if entry.is_file():
for e in extensions:
if entry.name.lower().endswith(".{}".format(e.lower())):
if entry.name.lower().endswith(f".{e.lower()}"):
if entry.path not in ignored:
yield entry.path
elif recursive and entry.is_dir():
......@@ -204,7 +202,7 @@ class Command(BaseCommand):
dest="prune",
default=False,
help=(
"Once the import is completed, prune tracks, ablums and artists that aren't linked to any upload."
"Once the import is completed, prune tracks, albums and artists that aren't linked to any upload."
),
)
......@@ -262,7 +260,7 @@ class Command(BaseCommand):
raise CommandError("Invalid library id")
if not library.actor.get_user():
raise CommandError("Library {} is not a local library".format(library.uuid))
raise CommandError(f"Library {library.uuid} is not a local library")
if options["in_place"]:
self.stdout.write(
......@@ -284,7 +282,7 @@ class Command(BaseCommand):
"Culprit: {}".format(p, import_path)
)
reference = options["reference"] or "cli-{}".format(timezone.now().isoformat())
reference = options["reference"] or f"cli-{timezone.now().isoformat()}"
import_url = "{}://{}/library/{}/upload?{}"
import_url = import_url.format(
......@@ -355,7 +353,7 @@ class Command(BaseCommand):
batch_duration = None
self.stdout.write("Starting import of new files…")
for i, entries in enumerate(batch(crawler, options["batch_size"])):
if options.get("update_cache", False) is True:
if options.get("update_cache", False):
# check to see if the scan was cancelled
if cache.get("fs-import:status") == "canceled":
raise CommandError("Import cancelled")
......@@ -364,12 +362,15 @@ class Command(BaseCommand):
time_stats = ""
if i > 0:
time_stats = " - running for {}s, previous batch took {}s".format(
int(time.time() - start_time), int(batch_duration),
int(time.time() - start_time),
int(batch_duration),
)
if entries:
self.stdout.write(
"Handling batch {} ({} items){}".format(
i + 1, len(entries), time_stats,
i + 1,
len(entries),
time_stats,
)
)
batch_errors = self.handle_batch(
......@@ -392,10 +393,10 @@ class Command(BaseCommand):
message.format(total - len(errors), int(time.time() - start_time))
)
if len(errors) > 0:
self.stderr.write("{} tracks could not be imported:".format(len(errors)))
self.stderr.write(f"{len(errors)} tracks could not be imported:")
for path, error in errors:
self.stderr.write("- {}: {}".format(path, error))
self.stderr.write(f"- {path}: {error}")
self.stdout.write(
"For details, please refer to import reference '{}' or URL {}".format(
......@@ -484,12 +485,12 @@ class Command(BaseCommand):
return errors
def filter_matching(self, matching, library):
sources = ["file://{}".format(p) for p in matching]
sources = [f"file://{p}" for p in matching]
# we skip reimport for path that are already found
# as a Upload.source
existing = library.uploads.filter(source__in=sources, import_status="finished")
existing = existing.values_list("source", flat=True)
existing = set([p.replace("file://", "", 1) for p in existing])
existing = {p.replace("file://", "", 1) for p in existing}
skipped = set(matching) & existing
result = {
"initial": matching,
......@@ -529,22 +530,25 @@ class Command(BaseCommand):
path, e.__class__.__name__, e
)
self.stderr.write(m)
errors.append((path, "{} {}".format(e.__class__.__name__, e)))
errors.append((path, f"{e.__class__.__name__} {e}"))
return errors
def setup_watcher(self, path, extensions, recursive, **kwargs):
watchdog_queue = queue.Queue()
# Set up a worker thread to process database load
worker = threading.Thread(
target=process_load_queue(self.stdout, **kwargs), args=(watchdog_queue,),
target=process_load_queue(self.stdout, **kwargs),
args=(watchdog_queue,),
)
worker.setDaemon(True)
worker.start()
# setup watchdog to monitor directory for trigger files
patterns = ["*.{}".format(e) for e in extensions]
patterns = [f"*.{e}" for e in extensions]
event_handler = Watcher(
stdout=self.stdout, queue=watchdog_queue, patterns=patterns,
stdout=self.stdout,
queue=watchdog_queue,
patterns=patterns,
)
observer = watchdog.observers.Observer()
observer.schedule(event_handler, path, recursive=recursive)
......@@ -552,9 +556,7 @@ class Command(BaseCommand):
try:
while True:
self.stdout.write(
"Watching for changes at {}…".format(path), ending="\r"
)
self.stdout.write(f"Watching for changes at {path}", ending="\r")
time.sleep(10)
if kwargs["prune"] and GLOBAL["need_pruning"]:
self.stdout.write("Some files were deleted, pruning library…")
......@@ -581,7 +583,14 @@ def prune():
def create_upload(
path, reference, library, async_, replace, in_place, dispatch_outbox, broadcast,
path,
reference,
library,
async_,
replace,
in_place,
dispatch_outbox,
broadcast,
):
import_handler = tasks.process_upload.delay if async_ else tasks.process_upload
upload = models.Upload(library=library, import_reference=reference)
......@@ -621,7 +630,7 @@ def process_load_queue(stdout, **kwargs):
for path, event in batched_events.copy().items():
if time.time() - event["time"] <= flush_delay:
continue
now = datetime.datetime.utcnow()
now = datetime.datetime.now(datetime.timezone.utc)
stdout.write(
"{} -- Processing {}:{}...\n".format(
now.strftime("%Y/%m/%d %H:%M:%S"), event["type"], event["path"]
......@@ -691,8 +700,10 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
to_update = (
existing_candidates.in_place()
.filter(source=source)
.select_related(
"track__attributed_to", "track__artist", "track__album__artist",
.select_related("track__attributed_to")
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
.first()
)
......@@ -715,7 +726,7 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
try:
tasks.update_track_metadata(audio_metadata, to_update.track)
except serializers.ValidationError as e:
stdout.write(" Invalid metadata: {}".format(e))
stdout.write(f" Invalid metadata: {e}")
else:
to_update.checksum = checksum
to_update.save(update_fields=["checksum"])
......@@ -752,7 +763,7 @@ def handle_moved(event, stdout, library, in_place, **kwargs):
existing_candidates = existing_candidates.in_place().filter(source=old_source)
existing = existing_candidates.first()
if existing:
stdout.write(" Updating path of existing file #{}".format(existing.pk))
stdout.write(f" Updating path of existing file #{existing.pk}")
existing.source = new_source
existing.save(update_fields=["source"])
......@@ -781,21 +792,24 @@ def check_updates(stdout, library, extensions, paths, batch_size):
for path in paths:
for ext in extensions:
queries.append(
Q(source__startswith="file://{}".format(path))
& Q(source__endswith=".{}".format(ext))
Q(source__startswith=f"file://{path}") & Q(source__endswith=f".{ext}")
)
query, remainder = queries[0], queries[1:]
for q in remainder:
query = q | query
existing = existing.filter(query)
total = existing.count()
stdout.write("Found {} files to check in database!".format(total))
stdout.write(f"Found {total} files to check in database!")
uploads = existing.order_by("source")
for i, rows in enumerate(batch(uploads.iterator(), batch_size)):
stdout.write("Handling batch {} ({} items)".format(i + 1, len(rows),))
stdout.write(
"Handling batch {} ({} items)".format(
i + 1,
len(rows),
)
)
for upload in rows:
check_upload(stdout, upload)
checked_paths.add(upload.source.replace("file://", "", 1))
......@@ -825,13 +839,13 @@ def check_upload(stdout, upload):
" Cannot update track metadata, track belongs to someone else"
)
else:
track = models.Track.objects.select_related("artist", "album__artist").get(
pk=upload.track_id
)
track = models.Track.objects.prefetch_related(
"artist_credit__artist", "album__artist_credit__artist"
).get(pk=upload.track_id)
try:
tasks.update_track_metadata(upload.get_metadata(), track)
except serializers.ValidationError as e:
stdout.write(" Invalid metadata: {}".format(e))
stdout.write(f" Invalid metadata: {e}")
return
except IntegrityError:
stdout.write(
......