diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 4330fb33c..99e47df5d 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1,5 +1,6 @@ import logging import os +import re import urllib.parse import uuid @@ -1580,6 +1581,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) @@ -1589,7 +1634,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) @@ -1701,7 +1746,7 @@ class UploadSerializer(jsonld.JsonLdSerializer): "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), @@ -1851,7 +1896,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1) 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) @@ -1960,7 +2005,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") diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 9fc7e9e72..fe1e2b0c9 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -719,7 +719,7 @@ class Track(APIModelMixin): @property def listen_url(self) -> str: # Not using reverse because this is slow - return f"/api/v1/listen/{self.uuid}/" + return f"/api/v2/listen/{self.uuid}/" @property def local_license(self): diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index d764e29b3..37883ca5c 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1268,7 +1268,7 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): "name": "Ignored", "published": published.isoformat(), "updated": updated.isoformat(), - "duration": 43, + "duration": "PT43S", "bitrate": 42, "size": 66, "url": {"href": "https://audio.file", "type": "Link", "mediaType": "audio/mp3"}, @@ -1337,7 +1337,7 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): assert track_create.call_count == 1 assert upload.fid == data["id"] assert upload.track.fid == data["track"]["id"] - assert upload.duration == data["duration"] + assert upload.duration == 43 assert upload.size == data["size"] assert upload.bitrate == data["bitrate"] assert upload.source == data["url"]["href"] @@ -1357,7 +1357,7 @@ def test_activity_pub_upload_serializer_from_ap_update(factories, mocker, now, r "name": "Ignored", "published": now.isoformat(), "updated": now.isoformat(), - "duration": 42, + "duration": "PT42S", "bitrate": 42, "size": 66, "url": { @@ -1376,7 +1376,7 @@ def test_activity_pub_upload_serializer_from_ap_update(factories, mocker, now, r upload.refresh_from_db() assert upload.fid == data["id"] - assert upload.duration == data["duration"] + assert upload.duration == 42 assert upload.size == data["size"] assert upload.bitrate == data["bitrate"] assert upload.source == data["url"]["href"] @@ -1408,7 +1408,7 @@ def test_activity_pub_audio_serializer_to_ap(factories): "name": upload.track.full_name, "published": upload.creation_date.isoformat(), "updated": upload.modification_date.isoformat(), - "duration": upload.duration, + "duration": "PT43S", "bitrate": upload.bitrate, "size": upload.size, "to": contexts.AS.Public, @@ -1777,7 +1777,7 @@ def test_channel_upload_serializer(factories): "content": common_utils.render_html(content.text, content.content_type), "to": "https://www.w3.org/ns/activitystreams#Public", "position": upload.track.position, - "duration": upload.duration, + "duration": "PT54S", "album": upload.track.album.fid, "disc": upload.track.disc_number, "copyright": upload.track.copyright, @@ -1826,7 +1826,7 @@ def test_channel_upload_serializer_from_ap_create(factories, now, mocker): "published": now.isoformat(), "mediaType": "text/html", "content": "
Hello
", - "duration": 543, + "duration": "PT543S", "position": 4, "disc": 2, "album": album.fid, @@ -1875,7 +1875,7 @@ def test_channel_upload_serializer_from_ap_create(factories, now, mocker): assert upload.mimetype == payload["url"][1]["mediaType"] assert upload.size == payload["url"][1]["size"] assert upload.bitrate == payload["url"][1]["bitrate"] - assert upload.duration == payload["duration"] + assert upload.duration == 543 assert upload.track.artist_credit.all()[0].artist == channel.artist assert upload.track.position == payload["position"] assert upload.track.disc_number == payload["disc"] @@ -1909,7 +1909,7 @@ def test_channel_upload_serializer_from_ap_update(factories, now, mocker): "published": now.isoformat(), "mediaType": "text/html", "content": "Hello
", - "duration": 543, + "duration": "PT543S", "position": 4, "disc": 2, "album": album.fid, @@ -1959,7 +1959,7 @@ def test_channel_upload_serializer_from_ap_update(factories, now, mocker): assert upload.mimetype == payload["url"][1]["mediaType"] assert upload.size == payload["url"][1]["size"] assert upload.bitrate == payload["url"][1]["bitrate"] - assert upload.duration == payload["duration"] + assert upload.duration == 543 assert upload.track.artist_credit.all()[0].artist == channel.artist assert upload.track.position == payload["position"] assert upload.track.disc_number == payload["disc"] diff --git a/changes/changelog.d/1566.bugfix b/changes/changelog.d/1566.bugfix new file mode 100644 index 000000000..be6a67fcf --- /dev/null +++ b/changes/changelog.d/1566.bugfix @@ -0,0 +1 @@ +ActivityStreams compliance: duration (#1566) diff --git a/docs/developer/federation/index.md b/docs/developer/federation/index.md index 38619c31f..59fd7bc51 100644 --- a/docs/developer/federation/index.md +++ b/docs/developer/federation/index.md @@ -798,8 +798,7 @@ An `Audio` object is a custom object used to store upload information. It extend - Integer - The bitrate of the audio in bytes/s * - `duration`* - - Integer - - The duration of the audio in seconds + - The duration of the audio as defined in [as](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration) * - `library`* - String (URI) - The ID of the audio's containing [`Library` object](#library) @@ -826,7 +825,7 @@ An `Audio` object is a custom object used to store upload information. It extend "name": "Krav Boca - Mortem", "size": 8656581, "bitrate": 320000, - "duration": 213, + "duration": "PT1312S, "library": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6", "updated": "2018-10-02T19:49:35.646372+00:00", "published": "2018-10-02T19:49:35.646359+00:00",