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

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
Show changes
Showing
with 1248 additions and 285 deletions
import cryptography
import logging
import datetime
import logging
import urllib.parse
import cryptography
from django.contrib.auth.models import AnonymousUser
from django.utils import timezone
from rest_framework import authentication
from rest_framework import exceptions as rest_exceptions
from rest_framework import authentication, exceptions as rest_exceptions
from funkwhale_api.common import preferences
from funkwhale_api.moderation import models as moderation_models
from . import actors, exceptions, keys, models, signing, tasks, utils
from . import actors, exceptions, keys, models, signing, tasks, utils
logger = logging.getLogger(__name__)
......@@ -53,7 +55,9 @@ class SignatureAuthentication(authentication.BaseAuthentication):
actor = actors.get_actor(actor_url)
except Exception as e:
logger.info(
"Discarding HTTP request from actor/domain %s, %s", actor_url, str(e),
"Discarding HTTP request from actor/domain %s, %s",
actor_url,
str(e),
)
raise rest_exceptions.AuthenticationFailed(
"Cannot fetch remote actor to authenticate signature"
......@@ -77,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
fetch_delay = 24 * 3600
now = timezone.now()
last_fetch = actor.domain.nodeinfo_fetch_date
if not actor.domain.is_local:
if not last_fetch or (
last_fetch < (now - datetime.timedelta(seconds=fetch_delay))
):
......
......@@ -293,7 +293,11 @@ CONTEXTS = [
"Album": "fw:Album",
"Track": "fw:Track",
"Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library",
"Playlist": "fw:Playlist",
"PlaylistTrack": "fw:PlaylistTrack",
"AudioCollection": "fw:AudioCollection",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
......@@ -302,13 +306,23 @@ CONTEXTS = [
"track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
"artist_credit": {
"@id": "fw:artist_credit",
"@type": "@id",
"@container": "@list",
},
"joinphrase": {"@id": "fw:joinphrase", "@type": "xsd:string"},
"credit": {"@id": "fw:credit", "@type": "xsd:string"},
"index": {"@id": "fw:index", "@type": "xsd:nonNegativeInteger"},
"released": {"@id": "fw:released", "@type": "xsd:date"},
"musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"},
"copyright": "fw:copyright",
"category": "schema:category",
"language": "schema:inLanguage",
"playlist": {"@id": "fw:playlist", "@type": "@id"},
}
},
},
......@@ -362,14 +376,14 @@ class NS:
def __getattr__(self, key):
if key not in self.conf["document"]["@context"]:
raise AttributeError(
"{} is not a valid property of context {}".format(key, self.baseUrl)
f"{key} is not a valid property of context {self.baseUrl}"
)
return self.baseUrl + key
class NoopContext:
def __getattr__(self, key):
return "_:{}".format(key)
return f"_:{key}"
NOOP = NoopContext()
......
from django.db import transaction
from rest_framework import decorators
from rest_framework import permissions
from rest_framework import response
from rest_framework import status
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import decorators, permissions, response, status
from funkwhale_api.common import utils as common_utils
from . import api_serializers
from . import filters
from . import models
from . import tasks
from . import utils
from . import api_serializers, filters, models, tasks, utils
def fetches_route():
......@@ -42,8 +35,16 @@ def fetches_route():
serializer = api_serializers.FetchSerializer(fetch)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action(
return extend_schema(methods=["post"], responses=api_serializers.FetchSerializer())(
extend_schema(
methods=["get"],
responses=api_serializers.FetchSerializer(many=True),
parameters=[OpenApiParameter("id", location="query", exclude=True)],
)(
decorators.action(
methods=["get", "post"],
detail=True,
permission_classes=[permissions.IsAuthenticated],
)(fetches)
)
)
......@@ -2,12 +2,13 @@ import uuid
import factory
import requests
import requests_http_signature
import requests_http_message_signatures
from django.conf import settings
from django.db.models.signals import post_save
from django.utils import timezone
from django.utils.http import http_date
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.users import factories as user_factories
from . import keys, models
......@@ -20,11 +21,10 @@ class SignatureAuthFactory(factory.Factory):
algorithm = "rsa-sha256"
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker("url")
use_auth_header = False
headers = ["(request-target)", "user-agent", "host", "date", "accept"]
class Meta:
model = requests_http_signature.HTTPSignatureAuth
model = requests_http_message_signatures.HTTPSignatureHeaderAuth
@registry.register(name="federation.SignedRequest")
......@@ -71,6 +71,8 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
nodeinfo_fetch_date = factory.LazyFunction(lambda: timezone.now())
allowed = None
reachable = True
last_successful_contact = None
class Meta:
model = "federation.Domain"
......@@ -105,7 +107,7 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
summary = factory.Faker("paragraph")
domain = factory.SubFactory(DomainFactory)
fid = factory.LazyAttribute(
lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username)
lambda o: f"https://{o.domain.name}/users/{o.preferred_username}"
)
followers_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}followers".format(
......@@ -127,9 +129,6 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta:
model = models.Actor
class Params:
with_real_keys = factory.Trait(keys=factory.LazyFunction(keys.get_key_pair),)
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
......@@ -139,7 +138,7 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
self.domain = models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME
)[0]
self.fid = "https://{}/actors/{}".format(self.domain, self.preferred_username)
self.fid = f"https://{self.domain}/actors/{self.preferred_username}"
self.save(update_fields=["domain", "fid"])
if not create:
if extracted and hasattr(extracted, "pk"):
......@@ -149,8 +148,26 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
if extracted and hasattr(extracted, "pk"):
extracted.actor = self
extracted.save(update_fields=["user"])
else:
@factory.post_generation
def user(self, create, extracted, **kwargs):
"""
Handle the creation or assignment of the related user instance.
If `actor__user` is passed, it will be linked; otherwise, no user is created.
"""
from funkwhale_api.users.factories import UserFactory
if not create:
return
if extracted: # If a User instance is provided
extracted.actor = self
extracted.save(update_fields=["actor"])
elif kwargs:
# Create a User linked to this Actor
self.user = UserFactory(actor=self, **kwargs)
else:
self.user = UserFactory(actor=self)
@registry.register
......@@ -164,25 +181,37 @@ class FollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Params:
local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
@classmethod
@factory.django.mute_signals(post_save)
def _create(cls, model_class, *args, **kwargs):
"""
Overrides Factory Boy's object creation to suppress post_save signals
only during this factory's create(). Needed because Follow creation trigger a remote library fetch
"""
return super()._create(model_class, *args, **kwargs)
@registry.register
class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
uuid = factory.Faker("uuid4")
actor = factory.SubFactory(ActorFactory)
privacy_level = "me"
name = factory.Faker("sentence")
description = factory.Faker("sentence")
name = privacy_level
uploads_count = 0
fid = factory.Faker("federation_url")
followers_url = factory.LazyAttribute(
lambda o: o.fid + "/followers" if o.fid else None
)
class Meta:
model = "music.Library"
class Params:
local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True)
fid=factory.Faker(
"federation_url",
local=True,
prefix="federation/music/libraries",
obj_uuid=factory.SelfAttribute("..uuid"),
),
actor=factory.SubFactory(ActorFactory, local=True),
)
......@@ -297,13 +326,13 @@ class NoteFactory(factory.Factory):
@registry.register(name="federation.AudioMetadata")
class AudioMetadataFactory(factory.Factory):
recording = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/recording/{}".format(uuid.uuid4())
lambda o: f"https://musicbrainz.org/recording/{uuid.uuid4()}"
)
artist = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/artist/{}".format(uuid.uuid4())
lambda o: f"https://musicbrainz.org/artist/{uuid.uuid4()}"
)
release = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/release/{}".format(uuid.uuid4())
lambda o: f"https://musicbrainz.org/release/{uuid.uuid4()}"
)
bitrate = 42
length = 43
......
import django_filters
from rest_framework import serializers
from . import models
from . import utils
from . import models, utils
class ActorRelatedField(serializers.EmailField):
......
import aiohttp
import asyncio
import functools
import logging
import aiohttp
import pyld.documentloader.requests
import pyld.jsonld
from django.conf import settings
import pyld.documentloader.requests
from rest_framework import serializers
from rest_framework.fields import empty
from . import contexts
logger = logging.getLogger(__name__)
def cached_contexts(loader):
functools.wraps(loader)
......@@ -56,7 +60,6 @@ def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]):
except KeyError:
# Nothing to do here if no context is available at all
pass
result = pyld.jsonld.expand(doc, options=options)
try:
# jsonld.expand returns a list, which is useless for us
......@@ -95,7 +98,7 @@ async def fetch_many(*ids, references=None):
"""
Given a list of object ids, will fetch the remote
representations for those objects, expand them
and return a dictionnary with id as the key and expanded document as the values
and return a dictionary with id as the key and expanded document as the values
"""
ids = set(ids)
results = references if references is not None else {}
......@@ -121,7 +124,7 @@ DEFAULT_PREPARE_CONFIG = {
def dereference(value, references):
"""
Given a payload and a dictonary containing ids and objects, will replace
Given a payload and a dictionary containing ids and objects, will replace
all the matching objects in the payload by the one in the references dictionary.
"""
......@@ -150,7 +153,6 @@ def dereference(value, references):
def get_value(value, keep=None, attr=None):
if keep == "first":
value = value[0]
if attr:
......@@ -165,10 +167,10 @@ def get_value(value, keep=None, attr=None):
def prepare_for_serializer(payload, config, fallbacks={}):
"""
Json-ld payloads, as returned by expand are quite complex to handle, because
every attr is basically a list of dictionnaries. To make code simpler,
every attr is basically a list of dictionaries. To make code simpler,
we use this function to clean the payload a little bit, base on the config object.
Config is a dictionnary, with keys being serializer field names, and values
Config is a dictionary, with keys being serializer field names, and values
being dictionaries describing how to handle this field.
"""
final_payload = {}
......@@ -188,11 +190,12 @@ def prepare_for_serializer(payload, config, fallbacks={}):
value = noop
if not aliases:
continue
for a in aliases:
try:
value = get_value(
payload[a["property"]], keep=a.get("keep"), attr=a.get("attr"),
payload[a["property"]],
keep=a.get("keep"),
attr=a.get("attr"),
)
except (IndexError, KeyError):
continue
......@@ -247,14 +250,13 @@ class JsonLdSerializer(serializers.Serializer):
def run_validation(self, data=empty):
if data and data is not empty:
self.jsonld_context = data.get("@context", [])
if self.context.get("expand", self.jsonld_expand):
try:
data = expand(data)
except ValueError as e:
raise serializers.ValidationError(
"{} is not a valid jsonld document: {}".format(data, e)
f"{data} is not a valid jsonld document: {e}"
)
try:
config = self.Meta.jsonld_mapping
......@@ -275,11 +277,11 @@ class JsonLdSerializer(serializers.Serializer):
for field in dereferenced_fields:
for i in get_ids(data[field]):
dereferenced_ids.add(i)
if dereferenced_ids:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
except RuntimeError as exception:
logger.debug(exception)
loop = asyncio.new_event_loop()
references = self.context.setdefault("references", {})
loop.run_until_complete(
......
import re
import urllib.parse
from django.conf import settings
from cryptography.hazmat.backends import default_backend as crypto_default_backend
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from django.conf import settings
KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"")
......
......@@ -9,7 +9,9 @@ def get_library_data(library_url, actor):
auth = signing.get_auth(actor.private_key, actor.private_key_id)
try:
response = session.get_session().get(
library_url, auth=auth, headers={"Accept": "application/activity+json"},
library_url,
auth=auth,
headers={"Accept": "application/activity+json"},
)
except requests.ConnectionError:
return {"errors": ["This library is not reachable"]}
......@@ -19,7 +21,7 @@ def get_library_data(library_url, actor):
elif scode == 403:
return {"errors": ["Permission denied while scanning library"]}
elif scode >= 400:
return {"errors": ["Error {} while fetching the library".format(scode)]}
return {"errors": [f"Error {scode} while fetching the library"]}
serializer = serializers.LibrarySerializer(data=response.json())
if not serializer.is_valid():
return {"errors": ["Invalid ActivityPub response from remote library"]}
......@@ -30,7 +32,9 @@ def get_library_data(library_url, actor):
def get_library_page(library, page_url, actor):
auth = signing.get_auth(actor.private_key, actor.private_key_id)
response = session.get_session().get(
page_url, auth=auth, headers={"Accept": "application/activity+json"},
page_url,
auth=auth,
headers={"Accept": "application/activity+json"},
)
serializer = serializers.CollectionPageSerializer(
data=response.json(),
......
......@@ -4,13 +4,12 @@ from funkwhale_api.common import utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models
MODELS = [
(music_models.Artist, ["fid"]),
(music_models.Album, ["fid"]),
(music_models.Track, ["fid"]),
(music_models.Upload, ["fid"]),
(music_models.Library, ["fid", "followers_url"]),
(music_models.Library, ["fid"]),
(
federation_models.Actor,
[
......@@ -31,7 +30,7 @@ MODELS = [
class Command(BaseCommand):
help = """
Find and replace wrong protocal/domain in local federation ids.
Find and replace wrong protocol/domain in local federation ids.
Use with caution and only if you know what you are doing.
"""
......@@ -68,9 +67,7 @@ class Command(BaseCommand):
for kls, fields in MODELS:
results[kls] = {}
for field in fields:
candidates = kls.objects.filter(
**{"{}__startswith".format(field): old_prefix}
)
candidates = kls.objects.filter(**{f"{field}__startswith": old_prefix})
results[kls][field] = candidates.count()
total = sum([t for k in results.values() for t in k.values()])
......@@ -93,9 +90,7 @@ class Command(BaseCommand):
)
else:
self.stdout.write(
"No objects found with prefix {}, exiting.".format(old_prefix)
)
self.stdout.write(f"No objects found with prefix {old_prefix}, exiting.")
return
if options["dry_run"]:
self.stdout.write(
......@@ -113,9 +108,7 @@ class Command(BaseCommand):
for kls, fields in results.items():
for field, count in fields.items():
self.stdout.write(
"Replacing {} on {} {}…".format(field, count, kls._meta.label)
)
self.stdout.write(f"Replacing {field} on {count} {kls._meta.label}")
candidates = kls.objects.all()
utils.replace_prefix(candidates, field, old=old_prefix, new=new_prefix)
self.stdout.write("")
......
......@@ -75,7 +75,7 @@ class Migration(migrations.Migration):
"last_fetch_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("manually_approves_followers", models.NullBooleanField(default=None)),
("manually_approves_followers", models.BooleanField(default=None, null=True)),
],
)
]
......@@ -77,7 +77,7 @@ class Migration(migrations.Migration):
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.NullBooleanField(default=None)),
("approved", models.BooleanField(default=None, null=True)),
(
"actor",
models.ForeignKey(
......
......@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="follow",
name="approved",
field=models.NullBooleanField(default=None),
field=models.BooleanField(default=None, null=True),
),
migrations.AddField(
model_name="library",
......
......@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("delivered", models.NullBooleanField(default=None)),
("delivered", models.BooleanField(default=None, null=True)),
("delivered_date", models.DateTimeField(blank=True, null=True)),
],
),
......@@ -69,7 +69,7 @@ class Migration(migrations.Migration):
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.NullBooleanField(default=None)),
("approved", models.BooleanField(default=None, null=True)),
],
),
migrations.RenameField("actor", "url", "fid"),
......
# Generated by Django 3.2.13 on 2022-06-27 19:15
import django.core.serializers.json
from django.db import migrations, models
import funkwhale_api.federation.models
class Migration(migrations.Migration):
dependencies = [
('federation', '0026_public_key_format'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='payload',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='actor',
name='manually_approves_followers',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='domain',
name='nodeinfo',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, max_length=50000),
),
migrations.AlterField(
model_name='fetch',
name='detail',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='follow',
name='approved',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='libraryfollow',
name='approved',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='librarytrack',
name='metadata',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
),
]
# Generated by Django 3.2.16 on 2022-10-27 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('federation', '0027_auto_20220627_1915'),
]
operations = [
migrations.AddField(
model_name='domain',
name='last_successful_contact',
field=models.DateTimeField(default=None, null=True),
),
migrations.AddField(
model_name='domain',
name='reachable',
field=models.BooleanField(default=True),
),
]
# Generated by Django 5.1.6 on 2025-08-04 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("federation", "0028_auto_20221027_1141"),
("music", "0061_migrate_libraries_to_playlist"),
]
operations = [
migrations.AddField(
model_name="domain",
name="reachable_retries",
field=models.PositiveIntegerField(default=0),
),
]
......@@ -3,16 +3,16 @@ import urllib.parse
import uuid
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.db.models.signals import post_save, pre_save, post_delete
from django.db.models import JSONField
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils
......@@ -30,6 +30,10 @@ TYPE_CHOICES = [
("Service", "Service"),
]
MAX_LENGTHS = {
"ACTOR_NAME": 200,
}
def empty_dict():
return {}
......@@ -48,7 +52,7 @@ class FederationMixin(models.Model):
abstract = True
@property
def is_local(self):
def is_local(self) -> bool:
return federation_utils.is_local(self.fid)
@property
......@@ -76,7 +80,7 @@ class ActorQuerySet(models.QuerySet):
)
qs = qs.annotate(
**{
"_usage_{}".format(s): models.Sum(
f"_usage_{s}": models.Sum(
"libraries__uploads__size", filter=uploads_query
)
}
......@@ -123,7 +127,9 @@ class Domain(models.Model):
)
# are interactions with this domain allowed (only applies when allow-listing is on)
allowed = models.BooleanField(default=None, null=True)
reachable = models.BooleanField(default=True)
last_successful_contact = models.DateTimeField(default=None, null=True)
reachable_retries = models.PositiveIntegerField(default=0)
objects = DomainQuerySet.as_manager()
def __str__(self):
......@@ -172,7 +178,7 @@ class Domain(models.Model):
return data
@property
def is_local(self):
def is_local(self) -> bool:
return self.name == settings.FEDERATION_HOSTNAME
......@@ -187,7 +193,7 @@ class Actor(models.Model):
followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
name = models.CharField(max_length=200, null=True, blank=True)
name = models.CharField(max_length=MAX_LENGTHS["ACTOR_NAME"], null=True, blank=True)
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
summary = models.CharField(max_length=500, null=True, blank=True)
summary_obj = models.ForeignKey(
......@@ -198,7 +204,7 @@ class Actor(models.Model):
private_key = models.TextField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None)
manually_approves_followers = models.BooleanField(default=None, null=True)
followers = models.ManyToManyField(
to="self",
symmetrical=False,
......@@ -213,7 +219,6 @@ class Actor(models.Model):
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
objects = ActorQuerySet.as_manager()
class Meta:
......@@ -221,34 +226,40 @@ class Actor(models.Model):
verbose_name = "Account"
def get_moderation_url(self):
return "/manage/moderation/accounts/{}".format(self.full_username)
return f"/manage/moderation/accounts/{self.full_username}"
@property
def webfinger_subject(self):
return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
return f"{self.preferred_username}@{settings.FEDERATION_HOSTNAME}"
@property
def private_key_id(self):
return "{}#main-key".format(self.fid)
return f"{self.fid}#main-key"
@property
def full_username(self):
return "{}@{}".format(self.preferred_username, self.domain_id)
def full_username(self) -> str:
return f"{self.preferred_username}@{self.domain_id}"
def __str__(self):
return "{}@{}".format(self.preferred_username, self.domain_id)
return f"{self.preferred_username}@{self.domain_id}"
@property
def is_local(self):
def is_local(self) -> bool:
return self.domain_id == settings.FEDERATION_HOSTNAME
def get_approved_followers(self):
follows = self.received_follows.filter(approved=True)
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def get_approved_followings(self):
follows = self.emitted_follows.filter(approved=True)
return Actor.objects.filter(pk__in=follows.values_list("target", flat=True))
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.privacy_level == "public":
return True
return False
def get_user(self):
......@@ -265,21 +276,21 @@ class Actor(models.Model):
def get_absolute_url(self):
if self.is_local:
return federation_utils.full_url("/@{}".format(self.preferred_username))
return federation_utils.full_url(f"/@{self.preferred_username}")
return self.url or self.fid
def get_current_usage(self):
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
data = {}
for s in ["draft", "pending", "skipped", "errored", "finished"]:
data[s] = getattr(actor, "_usage_{}".format(s)) or 0
data[s] = getattr(actor, f"_usage_{s}") or 0
data["total"] = sum(data.values())
return data
def get_stats(self):
from funkwhale_api.music import models as music_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
data = Actor.objects.filter(pk=self.pk).aggregate(
outbox_activities=models.Count("outbox_activities", distinct=True),
......@@ -336,8 +347,8 @@ class Actor(models.Model):
# matches, we consider the actor has the permission to manage
# the object
domain = self.domain_id
return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
"https://{}/".format(domain)
return obj.fid.startswith(f"http://{domain}/") or obj.fid.startswith(
f"https://{domain}/"
)
@property
......@@ -384,8 +395,7 @@ class Fetch(models.Model):
@property
def serializers(self):
from . import contexts
from . import serializers
from . import contexts, serializers
return {
contexts.FW.Artist: [serializers.ArtistSerializer],
......@@ -396,6 +406,7 @@ class Fetch(models.Model):
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
contexts.FW.Playlist: [serializers.PlaylistSerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],
......@@ -488,15 +499,13 @@ class AbstractFollow(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
approved = models.NullBooleanField(default=None)
approved = models.BooleanField(default=None, null=True)
class Meta:
abstract = True
def get_federation_id(self):
return federation_utils.full_url(
"{}#follows/{}".format(self.actor.fid, self.uuid)
)
return federation_utils.full_url(f"{self.actor.fid}#follows/{self.uuid}")
class Follow(AbstractFollow):
......@@ -590,7 +599,7 @@ class LibraryTrack(models.Model):
remote_response.raise_for_status()
extension = music_utils.get_ext_from_type(self.audio_mimetype)
title = " - ".join([self.title, self.album_title, self.artist_name])
filename = "{}.{}".format(title, extension)
filename = f"{title}.{extension}"
tmp_file = tempfile.TemporaryFile()
for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk)
......@@ -601,6 +610,7 @@ class LibraryTrack(models.Model):
@receiver(pre_save, sender=LibraryFollow)
@receiver(pre_save, sender=Follow)
def set_approved_updated(sender, instance, update_fields, **kwargs):
if not instance.pk or not instance.actor.is_local:
return
......@@ -615,24 +625,55 @@ def set_approved_updated(sender, instance, update_fields, **kwargs):
@receiver(post_save, sender=LibraryFollow)
@receiver(post_save, sender=Follow)
def update_denormalization_follow_approved(sender, instance, created, **kwargs):
from funkwhale_api.music import models as music_models
updated = getattr(instance, "_approved_updated", False)
if (created or updated) and instance.actor.is_local:
if isinstance(instance, LibraryFollow):
music_models.TrackActor.create_entries(
instance.target,
actor_ids=[instance.actor.pk],
delete_existing=not instance.approved,
)
elif isinstance(instance, Follow) and not instance.target.get_channel():
# we fetch the remote actor's libraries to make the uploads available locally
builtin_lib = federation_utils.get_or_create_builtin_actor_library(
instance.target, privacy_level="everyone"
)
followers_builtin_lib = (
federation_utils.get_or_create_builtin_actor_library(
instance.target, privacy_level="followers"
)
)
music_models.TrackActor.create_entries(
builtin_lib,
actor_ids=[instance.actor.pk],
delete_existing=not instance.approved,
)
music_models.TrackActor.create_entries(
followers_builtin_lib,
actor_ids=[instance.actor.pk],
delete_existing=not instance.approved,
)
@receiver(post_delete, sender=LibraryFollow)
@receiver(post_delete, sender=Follow)
def update_denormalization_follow_deleted(sender, instance, **kwargs):
from funkwhale_api.music import models as music_models
if instance.actor.is_local:
if isinstance(instance, LibraryFollow):
music_models.TrackActor.objects.filter(
actor=instance.actor, upload__in=instance.target.uploads.all()
).delete()
elif isinstance(instance, Follow):
builtin_lib_uploads = music_models.Upload.objects.filter(
library__actor=instance.target
)
music_models.TrackActor.objects.filter(
actor=instance.actor, upload__in=builtin_lib_uploads
).delete()
from funkwhale_api.moderation import mrf
from . import activity
......
......@@ -3,12 +3,12 @@ import uuid
from django.db.models import Q
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlist_models
from . import activity
from . import actors
from . import models
from . import serializers
from . import activity, actors, models, serializers
logger = logging.getLogger(__name__)
inbox = activity.InboxRouter()
......@@ -166,7 +166,7 @@ def outbox_follow(context):
def outbox_create_audio(context):
upload = context["upload"]
channel = upload.library.get_channel()
followers_target = channel.actor if channel else upload.library
followers_target = channel.actor if channel else upload.library.actor
actor = channel.actor if channel else upload.library.actor
if channel:
serializer = serializers.ChannelCreateUploadSerializer(upload)
......@@ -196,7 +196,8 @@ def inbox_create_audio(payload, context):
if is_channel:
channel = context["actor"].get_channel()
serializer = serializers.ChannelCreateUploadSerializer(
data=payload, context={"channel": channel},
data=payload,
context={"channel": channel},
)
else:
serializer = serializers.UploadSerializer(
......@@ -295,7 +296,7 @@ def inbox_delete_audio(payload, context):
upload_fids = [payload["object"]["id"]]
query = Q(fid__in=upload_fids) & (
Q(library__actor=actor) | Q(track__artist__channel__actor=actor)
Q(library__actor=actor) | Q(track__artist_credit__artist__channel__actor=actor)
)
candidates = music_models.Upload.objects.filter(query)
......@@ -309,8 +310,8 @@ def outbox_delete_audio(context):
uploads = context["uploads"]
library = uploads[0].library
channel = library.get_channel()
followers_target = channel.actor if channel else library
actor = channel.actor if channel else library.actor
followers_target = channel.actor if channel else actor
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
......@@ -579,7 +580,9 @@ def inbox_delete_album(payload, context):
logger.debug("Discarding deletion of empty library")
return
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor))
query = Q(fid=album_id) & (
Q(attributed_to=actor) | Q(artist_credit__artist__channel__actor=actor)
)
try:
album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist:
......@@ -592,9 +595,10 @@ def inbox_delete_album(payload, context):
@outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context):
album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = (
album.artist.channel.actor
if album.artist.get_channel()
album_artist.channel.actor
if album_artist.get_channel()
else album.attributed_to
)
actor = actor or actors.get_service_actor()
......@@ -610,3 +614,317 @@ def outbox_delete_album(context):
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_track_favorite(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Dislike", "object.type": "Track"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{"type": "Dislike", "object": {"type": "Track", "id": favorite.track.fid}}
)
yield {
"type": "Dislike",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Dislike", "object.type": "Track"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
track_id = payload["object"].get("id")
query = Q(track__fid=track_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug(
"Discarding deletion of unkwnown favorite with track : %s", track_id
)
return
favorite.delete()
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Listen",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Listen"})
def outbox_delete_listening(context):
listening = context["listening"]
actor = listening.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Listen", "id": listening.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Listen", "object.type": "Track"})
def inbox_create_listening(payload, context):
serializer = serializers.ListeningSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Listen"})
def inbox_delete_listening(payload, context):
actor = context["actor"]
listening_id = payload["object"].get("id")
query = Q(fid=listening_id) & Q(actor=actor)
try:
favorite = history_models.Listening.objects.get(query)
except history_models.Listening.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
return
favorite.delete()
@outbox.register({"type": "Update", "object.type": "Person"})
def outbox_update_actor(context):
actor = context["actor"]
# this is a bit hacky, but we want to send the privacy level of the user as audience
# to delete or fetch all user activities inheriting user.privacy_level
setattr(actor, "audience", actor.user.privacy_level)
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.ActorSerializer(actor).data}
)
yield {
"type": "Update",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Update", "object.type": "Person"})
def inbox_update_actor(payload, context):
actor = context["actor"]
serializer = serializers.ActorSerializer(data=payload["object"])
if serializer.is_valid() and "audience" in serializer.validated_data:
privacy_level = serializer.validated_data["audience"]
if privacy_level in [
"me",
"instance",
]:
# we delete all activities inheriting user.privacy_level
history_models.Listening.objects.filter(actor=actor).delete()
favorites_models.TrackFavorite.objects.filter(actor=actor).delete()
@outbox.register({"type": "Create", "object.type": "Playlist"})
def outbox_create_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"actor": playlist.actor,
"id": playlist.fid,
"object": serializers.PlaylistSerializer(playlist).data,
}
)
yield {
"type": "Create",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Playlist"})
def outbox_delete_playlist(context):
playlist = context["playlist"]
actor = playlist.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Playlist", "id": playlist.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Create", "object.type": "Playlist"})
def inbox_create_playlist(payload, context):
serializer = serializers.PlaylistSerializer(data=payload["object"])
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Playlist"})
def inbox_delete_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
query = Q(fid=playlist_id) & Q(actor=actor)
try:
playlist = playlist_models.Playlist.objects.get(query)
except playlist_models.Playlist.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", playlist_id)
return
playlist.playlist_tracks.all().delete()
playlist.delete()
@inbox.register({"type": "Update", "object.type": "Playlist"})
def inbox_update_playlist(payload, context):
"""If we receive an update on an unkwnown playlist, we create the playlist"""
playlist_id = payload["object"].get("id")
serializer = serializers.PlaylistSerializer(data=payload["object"])
if serializer.is_valid(raise_exception=True):
playlist = serializer.save()
# we update the playlist.library to get the plt.track.uploads locally
if follows := playlist.library.received_follows.filter(approved=True):
playlist.library.schedule_scan(follows[0].actor, force=True)
# we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities
playlist.schedule_scan(actors.get_service_actor(), force=True)
return
else:
logger.debug(
"Discarding update of playlist_id %s because of payload errors: %s",
playlist_id,
serializer.errors,
)
@outbox.register({"type": "Update", "object.type": "Playlist"})
def outbox_update_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.PlaylistSerializer(playlist).data}
)
yield {
"type": "Update",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@inbox.register({"type": "Update", "object.type": "AudioCollection"})
def inbox_update_audiocollection(payload, context):
serializer = serializers.AudioCollectionSerializer(
data=payload["object"], context=context
)
serializer.is_valid(raise_exception=True)
serializer.save()
@outbox.register({"type": "Update", "object.type": "AudioCollection"})
def outbox_update_audiocollection(context):
audios = context["audios"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.AudioCollectionSerializer(audios).data}
)
yield {
"type": "Update",
"actor": audios[0].library.actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Delete", "object.type": "AudioCollection"})
def inbox_delete_audiocollection(payload, context):
serializer = serializers.AudioCollectionSerializer(
data=payload["object"], context=context
)
serializer.is_valid(raise_exception=True)
for upload in serializer.validated_data["items"]:
music_models.Upload.objects.filter(fid=upload["id"]).delete()
@outbox.register({"type": "Delete", "object.type": "AudioCollection"})
def outbox_delete_audiocollection(context):
audios = context["audios"]
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": serializers.AudioCollectionSerializer(audios).data}
)
yield {
"type": "Delete",
"actor": audios[0].library.actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
import logging
import os
import re
import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__)
......@@ -116,7 +121,7 @@ class MediaSerializer(jsonld.JsonLdSerializer):
if not is_mimetype(v, self.allowed_mimetypes):
raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
f"Invalid mimetype {v}. Allowed: {self.allowed_mimetypes}"
)
return v
......@@ -237,7 +242,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
manuallyApprovesFollowers = serializers.BooleanField(
required=False, allow_null=True
)
name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True
)
......@@ -245,6 +252,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
followers = serializers.URLField(max_length=500, required=False)
following = serializers.URLField(max_length=500, required=False, allow_null=True)
......@@ -259,6 +267,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
attributedTo = serializers.URLField(max_length=500, required=False)
tags = serializers.ListField(min_length=0, required=False, allow_null=True)
audience = serializers.ChoiceField(
fields.PRIVACY_LEVEL_CHOICES, required=False, allow_null=True
)
def validate_tags(self, tags):
valid_tags = []
......@@ -297,6 +308,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"category": jsonld.first_val(contexts.SC.category),
"audience": jsonld.first_id(contexts.AS.audience),
# "language": jsonld.first_val(contexts.SC.inLanguage),
}
......@@ -331,15 +343,18 @@ class ActorSerializer(jsonld.JsonLdSerializer):
urls.append(
{"type": "Link", "href": instance.url, "mediaType": "text/html"}
)
if hasattr(instance, "audience"):
ret["audience"] = instance.audience
channel = instance.get_channel()
if channel:
ret["url"] = [
{
"type": "Link",
"href": instance.channel.get_absolute_url()
"href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url(),
else instance.get_absolute_url()
),
"mediaType": "text/html",
},
{
......@@ -369,7 +384,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["publicKey"] = {
"owner": instance.fid,
"publicKeyPem": instance.public_key,
"id": "{}#main-key".format(instance.fid),
"id": f"{instance.fid}#main-key",
}
ret["endpoints"] = {}
......@@ -433,9 +448,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file(
actor,
"attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
rss_url = get_by_media_type(
......@@ -451,7 +468,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
actor,
rss_url=rss_url,
attributed_to_fid=attributed_to,
**self.validated_data
**self.validated_data,
)
return actor
......@@ -488,9 +505,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
common_utils.attach_file(
artist,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags)
......@@ -500,7 +519,10 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
)
library = attributed_to.libraries.create(
privacy_level="everyone", name=artist_defaults["name"], fid=fid, uuid=uid,
privacy_level="everyone",
name=artist_defaults["name"],
fid=fid,
uuid=uid,
)
else:
library = artist.channel.library
......@@ -512,7 +534,9 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
"library": library,
}
channel, created = audio_models.Channel.objects.update_or_create(
actor=actor, attributed_to=attributed_to, defaults=channel_defaults,
actor=actor,
attributed_to=attributed_to,
defaults=channel_defaults,
)
return channel
......@@ -636,7 +660,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
else:
......@@ -729,9 +752,7 @@ class FollowActionSerializer(serializers.Serializer):
.get()
)
except follow_class.DoesNotExist:
raise serializers.ValidationError(
"No follow to {}".format(self.action_type)
)
raise serializers.ValidationError(f"No follow to {self.action_type}")
return validated_data
def to_representation(self, instance):
......@@ -742,7 +763,7 @@ class FollowActionSerializer(serializers.Serializer):
return {
"@context": jsonld.get_default_context(),
"id": instance.get_federation_id() + "/{}".format(self.action_type),
"id": instance.get_federation_id() + f"/{self.action_type}",
"type": self.action_type.title(),
"actor": actor.fid,
"object": FollowSerializer(instance).data,
......@@ -750,7 +771,6 @@ class FollowActionSerializer(serializers.Serializer):
class AcceptFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Accept"])
action_type = "accept"
......@@ -760,11 +780,11 @@ class AcceptFollowSerializer(FollowActionSerializer):
follow.save()
if follow.target._meta.label == "music.Library":
follow.target.schedule_scan(actor=follow.actor)
return follow
class RejectFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Reject"])
action_type = "reject"
......@@ -808,7 +828,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
......@@ -848,7 +870,7 @@ class ActorWebfingerSerializer(serializers.Serializer):
def to_representation(self, instance):
data = {}
data["subject"] = "acct:{}".format(instance.webfinger_subject)
data["subject"] = f"acct:{instance.webfinger_subject}"
data["links"] = [
{"rel": "self", "href": instance.fid, "type": "application/activity+json"}
]
......@@ -874,8 +896,7 @@ class ActivitySerializer(serializers.Serializer):
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError("Unsupported type {}".format(type))
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
......@@ -926,10 +947,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data):
UNSET = object()
additional_fields = {}
for field in ["name", "summary"]:
for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET)
if v == UNSET:
continue
# in some cases we use the serializer context to pass objects instances, we don't want to add them
if not isinstance(v, str) or isinstance(v, dict):
continue
additional_fields[field] = v
return additional_fields
......@@ -960,7 +984,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
data = {
"id": conf["id"],
"attributedTo": conf["actor"].fid,
"totalItems": paginator.count,
......@@ -969,10 +993,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
"first": first,
"last": last,
}
d.update(get_additional_fields(conf))
data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
class LibrarySerializer(PaginatedCollectionSerializer):
......@@ -982,8 +1006,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
......@@ -1000,9 +1022,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
},
......@@ -1024,18 +1044,20 @@ class LibrarySerializer(PaginatedCollectionSerializer):
conf = {
"id": library.fid,
"name": library.name,
"summary": library.description,
"page_size": 100,
"attributedTo": library.actor,
"actor": library.actor,
"items": library.uploads.for_federation(),
"items": (
library.uploads.for_federation()
if not library.playlist_uploads.all()
else library.playlist_uploads.for_federation()
),
"type": "Library",
}
r = super().to_representation(conf)
r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else ""
)
r["followers"] = library.followers_url
return r
def create(self, validated_data):
......@@ -1055,9 +1077,8 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={
"uploads_count": validated_data["totalItems"],
"name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]],
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
},
)
return library
......@@ -1123,7 +1144,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"last": last,
"items": [
conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False}
i,
context={
"actor": conf["actor"],
"library": conf.get("library", None),
"include_ap_context": False,
},
).data
for i in page.object_list
],
......@@ -1158,7 +1184,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
def repr_tag(tag_name):
return {"type": "Hashtag", "name": "#{}".format(tag_name)}
return {"type": "Hashtag", "name": f"#{tag_name}"}
def include_content(repr, content_obj):
......@@ -1217,12 +1243,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
self.updateable_fields, validated_data, instance
)
updated_fields = self.validate_updated_data(instance, updated_fields)
set_ac = False
if "artist_credit" in updated_fields:
artist_credit = updated_fields.pop("artist_credit")
set_ac = True
if creating:
instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields
)
if set_ac:
instance.artist_credit.set(artist_credit)
else:
music_tasks.update_library_entity(instance, updated_fields)
obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags)
......@@ -1284,7 +1320,6 @@ class ArtistSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
......@@ -1296,9 +1331,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
......@@ -1310,12 +1345,53 @@ class ArtistSerializer(MusicEntitySerializer):
create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data,
"joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
}
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
......@@ -1328,7 +1404,7 @@ class AlbumSerializer(MusicEntitySerializer):
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
("_artist", "artist"),
("artist_credit", "artist_credit"),
]
class Meta:
......@@ -1337,62 +1413,60 @@ class AlbumSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
data = {
"type": "Album",
"id": instance.fid,
"name": instance.title,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat()
if instance.release_date
else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"released": (
instance.release_date.isoformat() if instance.release_date else None
),
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
if instance.artist.get_channel():
d["artists"] = [
{
"type": instance.artist.channel.actor.type,
"id": instance.artist.channel.actor.fid,
}
]
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
data["artist_credit"] = ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data
]
include_content(d, instance.description)
include_content(data, instance.description)
if instance.attachment_cover:
include_image(d, instance.attachment_cover)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
def validate(self, data):
validated_data = super().validate(data)
if not self.parent:
artist_data = validated_data["artists"][0]
if artist_data.get("type", "Artist") == "Artist":
validated_data["_artist"] = utils.retrieve_ap_object(
artist_data["id"],
artist_credit_data = validated_data["artist_credit"]
if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
acs = []
for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Artist,
serializer_class=ArtistSerializer,
queryset=music_models.ArtistCredit,
serializer_class=ArtistCreditSerializer,
)
)
validated_data["artist_credit"] = acs
else:
# we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"])
validated_data["_artist"] = actor.channel.artist
actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data
......@@ -1402,7 +1476,7 @@ class AlbumSerializer(MusicEntitySerializer):
class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
......@@ -1430,7 +1504,7 @@ class TrackSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
......@@ -1440,7 +1514,7 @@ class TrackSerializer(MusicEntitySerializer):
)
def to_representation(self, instance):
d = {
data = {
"type": "Track",
"id": instance.fid,
"name": instance.title,
......@@ -1448,29 +1522,32 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position,
"disc": instance.disc_number,
"license": instance.local_license["identifiers"][0]
"license": (
instance.local_license["identifiers"][0]
if instance.local_license
else None,
else None
),
"copyright": instance.copyright if instance.copyright else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
],
"artist_credit": ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data,
"album": AlbumSerializer(
instance.album, context={"include_ap_context": False}
).data,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
include_content(data, instance.description)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
@transaction.atomic
def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks
......@@ -1486,18 +1563,21 @@ class TrackSerializer(MusicEntitySerializer):
validated_data, "album.attributedTo", permissive=True
)
)
artists = (
common_utils.recursive_getattr(validated_data, "artists", permissive=True)
artist_credit = (
common_utils.recursive_getattr(
validated_data, "artist_credit", permissive=True
)
or []
)
album_artists = (
album_artists_credit = (
common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True
validated_data, "album.artist_credit", permissive=True
)
or []
)
for artist in artists + album_artists:
actors_to_fetch.add(artist.get("attributedTo"))
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch:
if not url:
......@@ -1510,8 +1590,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity")
if from_activity:
metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track
def update(self, obj, validated_data):
......@@ -1520,6 +1601,50 @@ class TrackSerializer(MusicEntitySerializer):
return super().update(obj, validated_data)
def duration_int_to_xml(duration):
if not duration:
return None
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
ret = "P"
days, seconds = divmod(int(duration), multipliers["D"])
ret += f"{days:d}DT" if days > 0 else "T"
hours, seconds = divmod(seconds, multipliers["H"])
ret += f"{hours:d}H" if hours > 0 else ""
minutes, seconds = divmod(seconds, multipliers["M"])
ret += f"{minutes:d}M" if minutes > 0 else ""
ret += f"{seconds:d}S" if seconds > 0 or ret == "PT" else ""
return ret
class DayTimeDurationSerializer(serializers.DurationField):
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
def to_internal_value(self, value):
if isinstance(value, float):
return value
parsed = re.match(
r"P([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]+)?S)?", str(value)
)
if parsed is not None:
return int(
sum(
[
self.multipliers[s[-1]] * float("0" + s[:-1])
for s in parsed.groups()
if s is not None
]
)
)
self.fail(
"invalid", format="https://www.w3.org/TR/xmlschema11-2/#dayTimeDuration"
)
def to_representation(self, value):
duration_int_to_xml(value)
class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500)
......@@ -1529,7 +1654,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0)
size = serializers.IntegerField(min_value=0)
duration = serializers.IntegerField(min_value=0)
duration = DayTimeDurationSerializer(min_value=0)
track = TrackSerializer(required=True)
......@@ -1565,8 +1690,9 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v):
lb = self.context.get("library")
if lb:
if lb.fid != v:
raise serializers.ValidationError("Invalid library")
# the upload can come from a playlist lib
if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb
actor = self.context.get("actor")
......@@ -1578,10 +1704,10 @@ class UploadSerializer(jsonld.JsonLdSerializer):
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
except Exception:
raise serializers.ValidationError("Invalid library")
except Exception as e:
raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library")
raise serializers.ValidationError("Invalid library, actor check fails")
return library
def update(self, instance, validated_data):
......@@ -1606,6 +1732,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
"size": validated_data["size"],
"bitrate": validated_data["bitrate"],
"import_status": "finished",
"library": validated_data["library"],
}
return music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=data
......@@ -1632,16 +1759,17 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data)
def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track
d = {
"type": "Audio",
"id": instance.get_federation_id(),
"library": instance.library.fid,
"library": lib.fid,
"name": track.full_name,
"published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate,
"size": instance.size,
"duration": instance.duration,
"duration": duration_int_to_xml(instance.duration),
"url": [
{
"href": utils.full_url(instance.listen_url_no_download),
......@@ -1655,10 +1783,8 @@ class UploadSerializer(jsonld.JsonLdSerializer):
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": contexts.AS.Public
if instance.library.privacy_level == "everyone"
else "",
"attributedTo": instance.library.actor.fid,
"to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
"attributedTo": lib.actor.fid,
}
if instance.modification_date:
d["updated"] = instance.modification_date.isoformat()
......@@ -1697,9 +1823,7 @@ class FlagSerializer(jsonld.JsonLdSerializer):
try:
return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Unknown id {} for reported object".format(v)
)
raise serializers.ValidationError(f"Unknown id {v} for reported object")
def validate_type(self, tags):
if tags:
......@@ -1734,7 +1858,8 @@ class FlagSerializer(jsonld.JsonLdSerializer):
}
report, created = moderation_models.Report.objects.update_or_create(
fid=validated_data["id"], defaults=kwargs,
fid=validated_data["id"],
defaults=kwargs,
)
moderation_signals.report_created.send(sender=None, report=report)
return report
......@@ -1777,7 +1902,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.filter(track__artist=channel.artist),
.filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection",
}
r = super().to_representation(conf)
......@@ -1788,16 +1913,15 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = TruncatedCharField(truncate_length=music_models.MAX_LENGTHS["TRACK_TITLE"])
name = serializers.CharField()
published = serializers.DateTimeField(required=False)
duration = serializers.IntegerField(min_value=0, required=False)
duration = DayTimeDurationSerializer(required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
copyright = TruncatedCharField(
truncate_length=music_models.MAX_LENGTHS["COPYRIGHT"],
copyright = serializers.CharField(
allow_null=True,
required=False,
)
......@@ -1848,7 +1972,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
actor=actors.get_service_actor(),
serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter(
artist__channel=self.context["channel"]
artist_credit__artist__channel=self.context["channel"]
),
)
......@@ -1879,9 +2003,9 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
"name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(),
"to": contexts.AS.Public
if upload.library.privacy_level == "everyone"
else "",
"to": (
contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
),
"url": [
{
"type": "Link",
......@@ -1900,7 +2024,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, upload.duration, "duration")
include_if_not_none(data, duration_int_to_xml(upload.duration), "duration")
include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright")
......@@ -1911,7 +2035,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags:
data["tag"] = [repr_tag(name) for name in sorted(set(tags))]
data["summary"] = " ".join(["#{}".format(name) for name in tags])
data["summary"] = " ".join([f"#{name}" for name in tags])
if self.context.get("include_ap_context", True):
data["@context"] = jsonld.get_default_context()
......@@ -1927,7 +2051,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
now = timezone.now()
track_defaults = {
"fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1),
"title": validated_data["name"],
......@@ -1940,17 +2063,42 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create(
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults
fid=validated_data["id"],
defaults=track_defaults,
)
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data:
new_value = self.validated_data["image"]
common_utils.attach_file(
track,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
common_utils.attach_content(
......@@ -2032,7 +2180,7 @@ class DeleteSerializer(jsonld.JsonLdSerializer):
try:
obj = utils.get_object_by_fid(url)
except utils.ObjectDoesNotExist:
raise serializers.ValidationError("No object matching {}".format(url))
raise serializers.ValidationError(f"No object matching {url}")
if isinstance(obj, music_models.Upload):
obj = obj.track
......@@ -2074,3 +2222,324 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
"audience": favorite.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
"audience": listening.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack])
id = serializers.URLField(max_length=500)
track = serializers.URLField(max_length=500)
index = serializers.IntegerField()
creation_date = serializers.DateTimeField()
playlist = serializers.URLField(max_length=500, required=False)
class Meta:
model = playlists_models.PlaylistTrack
jsonld_mapping = {
"track": jsonld.first_id(contexts.FW.track),
"playlist": jsonld.first_id(contexts.FW.playlist),
"index": jsonld.first_val(contexts.FW.index),
"creation_date": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, plt):
payload = {
"type": "PlaylistTrack",
"id": plt.fid,
"track": plt.track.fid,
"index": plt.index,
"attributedTo": plt.playlist.actor.fid,
"published": plt.creation_date.isoformat(),
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
if self.context.get("include_playlist", True):
payload["playlist"] = plt.playlist.fid
return payload
def create(self, validated_data):
track = utils.retrieve_ap_object(
validated_data["track"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Track,
serializer_class=TrackSerializer,
)
playlist = utils.retrieve_ap_object(
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistSerializer,
)
defaults = {
"track": track,
"index": validated_data["index"],
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
if existing_plt := playlists_models.PlaylistTrack.objects.filter(
playlist=playlist, index=validated_data["index"]
):
existing_plt.delete()
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
**{
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
"fid": validated_data["id"],
},
)
return plt
class PlaylistSerializer(jsonld.JsonLdSerializer):
"""
Used for playlist activities
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create])
id = serializers.URLField(max_length=500)
uuid = serializers.UUIDField(required=False)
name = serializers.CharField(required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
published = serializers.DateTimeField(required=False)
updated = serializers.DateTimeField(required=False)
audience = serializers.ChoiceField(
choices=fields.PRIVACY_LEVEL_CHOICES,
)
library = serializers.URLField(max_length=500, required=True)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
]
class Meta:
model = playlists_models.Playlist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"library": jsonld.first_id(contexts.FW.library),
},
)
def to_representation(self, playlist):
payload = {
"type": "Playlist",
"id": playlist.fid,
"name": playlist.name,
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
"library": playlist.library.fid,
}
if playlist.modification_date:
payload["updated"] = playlist.modification_date.isoformat()
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor,
serializer_class=ActorSerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
"fid": validated_data["id"],
"uuid": validated_data.get(
"uuid", validated_data["id"].rstrip("/").split("/")[-1]
),
},
)
if created or ("library" in validated_data != playlist.library.fid):
library = utils.retrieve_ap_object(
validated_data["library"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
playlist.library = library
playlist.save()
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
validated_data["audience"] = "everyone"
return validated_data
def update(self, instance, validated_data):
return self.create(validated_data)
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
Used for the federation view.
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist])
def to_representation(self, playlist):
conf = {
"id": playlist.fid,
"name": playlist.name,
"page_size": 100,
"actor": playlist.actor,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"tracks",
),
"type": "Playlist",
"library": playlist.library.fid,
"published": playlist.creation_date.isoformat(),
}
r = super().to_representation(conf)
return r
class AudioCollectionSerializer(jsonld.JsonLdSerializer):
"""
Used for the activities
"""
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.FW.AudioCollection]
)
actor = serializers.URLField(max_length=500, required=False)
audience = serializers.ChoiceField(
choices=fields.PRIVACY_LEVEL_CHOICES,
required=True,
)
totalItems = serializers.IntegerField()
items = UploadSerializer(many=True)
class Meta:
jsonld_mapping = {
"audience": jsonld.first_id(contexts.AS.audience),
"actor": jsonld.first_id(contexts.AS.actor),
"totalItems": jsonld.first_val(contexts.AS.totalItems),
"items": jsonld.raw(contexts.AS.items),
}
def to_representation(self, audios):
conf = {
"type": "AudioCollection",
"actor": audios[0].library.actor.fid,
"audience": audios[0].library.privacy_level,
"totalItems": len(audios),
"items": UploadSerializer(audios, many=True).data,
}
if self.context.get("include_ap_context", True):
conf["@context"] = jsonld.get_default_context()
return conf
def create(self, validated_data):
for upload_data in validated_data["items"]:
UploadSerializer().create(upload_data)
return validated_data["items"]
def update(self, instance, validated_data):
return self.create(validated_data)