serializers.py 33.4 KB
Newer Older
1
2
3
4
5
import datetime
import logging
import time
import uuid

6
from django.conf import settings
7
from django.db import transaction
8
9
10
11
12
13
from django.db.models import Q
from django.utils import timezone

import feedparser
import requests
import pytz
14
15
16

from rest_framework import serializers

17
from django.templatetags.static import static
18
from django.urls import reverse
19

20
21
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
22
from funkwhale_api.common import locales
23
from funkwhale_api.common import preferences
24
25
from funkwhale_api.common import session
from funkwhale_api.federation import actors
26
from funkwhale_api.federation import models as federation_models
27
from funkwhale_api.federation import serializers as federation_serializers
28
from funkwhale_api.federation import utils as federation_utils
29
from funkwhale_api.moderation import mrf
30
31
32
33
from funkwhale_api.music import models as music_models
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import serializers as tags_serializers
34
from funkwhale_api.users import serializers as users_serializers
35

36
from . import categories
37
38
39
from . import models


40
41
42
logger = logging.getLogger(__name__)


43
44
45
46
class ChannelMetadataSerializer(serializers.Serializer):
    itunes_category = serializers.ChoiceField(
        choices=categories.ITUNES_CATEGORIES, required=True
    )
47
    itunes_subcategory = serializers.CharField(required=False, allow_null=True)
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
    language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
    copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
    owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
    owner_email = serializers.EmailField(required=False, allow_null=True)
    explicit = serializers.BooleanField(required=False)

    def validate(self, validated_data):
        validated_data = super().validate(validated_data)
        subcategory = self._validate_itunes_subcategory(
            validated_data["itunes_category"], validated_data.get("itunes_subcategory")
        )
        if subcategory:
            validated_data["itunes_subcategory"] = subcategory
        return validated_data

    def _validate_itunes_subcategory(self, parent, child):
        if not child:
            return

        if child not in categories.ITUNES_CATEGORIES[parent]:
            raise serializers.ValidationError(
                '"{}" is not a valid subcategory for "{}"'.format(child, parent)
            )

        return child


75
76
class ChannelCreateSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
77
78
79
80
    username = serializers.CharField(
        max_length=music_models.MAX_LENGTHS["ARTIST_NAME"],
        validators=[users_serializers.ASCIIUsernameValidator()],
    )
81
    description = common_serializers.ContentSerializer(allow_null=True)
82
    tags = tags_serializers.TagsListField()
83
84
85
    content_category = serializers.ChoiceField(
        choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
    )
86
    metadata = serializers.DictField(required=False)
87
    cover = music_serializers.COVER_WRITE_FIELD
88
89

    def validate(self, validated_data):
90
91
92
93
94
        existing_channels = self.context["actor"].owned_channels.count()
        if existing_channels >= preferences.get("audio__max_channels"):
            raise serializers.ValidationError(
                "You have reached the maximum amount of allowed channels"
            )
95
96
97
98
99
100
101
102
        validated_data = super().validate(validated_data)
        metadata = validated_data.pop("metadata", {})
        if validated_data["content_category"] == "podcast":
            metadata_serializer = ChannelMetadataSerializer(data=metadata)
            metadata_serializer.is_valid(raise_exception=True)
            metadata = metadata_serializer.validated_data
        validated_data["metadata"] = metadata
        return validated_data
103

104
    def validate_username(self, value):
105
106
107
        if value.lower() in [n.lower() for n in settings.ACCOUNT_USERNAME_BLACKLIST]:
            raise serializers.ValidationError("This username is already taken")

108
109
110
111
112
113
114
        matching = federation_models.Actor.objects.local().filter(
            preferred_username__iexact=value
        )
        if matching.exists():
            raise serializers.ValidationError("This username is already taken")
        return value

115
116
    @transaction.atomic
    def create(self, validated_data):
117
118
        from . import views

119
        cover = validated_data.pop("cover", None)
120
        description = validated_data.get("description")
121
        artist = music_models.Artist.objects.create(
122
123
124
            attributed_to=validated_data["attributed_to"],
            name=validated_data["name"],
            content_category=validated_data["content_category"],
125
            attachment_cover=cover,
126
        )
127
        common_utils.attach_content(artist, "description", description)
128

129
130
131
132
        if validated_data.get("tags", []):
            tags_models.set_tags(artist, *validated_data["tags"])

        channel = models.Channel(
133
134
135
            artist=artist,
            attributed_to=validated_data["attributed_to"],
            metadata=validated_data["metadata"],
136
137
        )
        channel.actor = models.generate_actor(
138
            validated_data["username"], name=validated_data["name"],
139
140
141
142
        )

        channel.library = music_models.Library.objects.create(
            name=channel.actor.preferred_username,
143
            privacy_level="everyone",
144
145
146
            actor=validated_data["attributed_to"],
        )
        channel.save()
147
        channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
148
149
150
        return channel

    def to_representation(self, obj):
151
        return ChannelSerializer(obj, context=self.context).data
152
153


154
155
156
NOOP = object()


157
158
class ChannelUpdateSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
159
    description = common_serializers.ContentSerializer(allow_null=True)
160
    tags = tags_serializers.TagsListField()
161
162
163
    content_category = serializers.ChoiceField(
        choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
    )
164
    metadata = serializers.DictField(required=False)
165
    cover = music_serializers.COVER_WRITE_FIELD
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190

    def validate(self, validated_data):
        validated_data = super().validate(validated_data)
        require_metadata_validation = False
        new_content_category = validated_data.get("content_category")
        metadata = validated_data.pop("metadata", NOOP)
        if (
            new_content_category == "podcast"
            and self.instance.artist.content_category != "postcast"
        ):
            # updating channel, setting as podcast
            require_metadata_validation = True
        elif self.instance.artist.content_category == "postcast" and metadata != NOOP:
            # channel is podcast, and metadata was updated
            require_metadata_validation = True
        else:
            metadata = self.instance.metadata

        if require_metadata_validation:
            metadata_serializer = ChannelMetadataSerializer(data=metadata)
            metadata_serializer.is_valid(raise_exception=True)
            metadata = metadata_serializer.validated_data

        validated_data["metadata"] = metadata
        return validated_data
191
192
193
194
195
196

    @transaction.atomic
    def update(self, obj, validated_data):
        if validated_data.get("tags") is not None:
            tags_models.set_tags(obj.artist, *validated_data["tags"])
        actor_update_fields = []
197
        artist_update_fields = []
198

199
200
201
        obj.metadata = validated_data["metadata"]
        obj.save(update_fields=["metadata"])

202
        if "description" in validated_data:
Agate's avatar
Agate committed
203
            common_utils.attach_content(
204
205
206
                obj.artist, "description", validated_data["description"]
            )

207
208
        if "name" in validated_data:
            actor_update_fields.append(("name", validated_data["name"]))
209
210
211
212
213
214
            artist_update_fields.append(("name", validated_data["name"]))

        if "content_category" in validated_data:
            artist_update_fields.append(
                ("content_category", validated_data["content_category"])
            )
215

216
217
218
        if "cover" in validated_data:
            artist_update_fields.append(("attachment_cover", validated_data["cover"]))

219
220
221
222
        if actor_update_fields:
            for field, value in actor_update_fields:
                setattr(obj.actor, field, value)
            obj.actor.save(update_fields=[f for f, _ in actor_update_fields])
223
224
225
226
227
228

        if artist_update_fields:
            for field, value in artist_update_fields:
                setattr(obj.artist, field, value)
            obj.artist.save(update_fields=[f for f, _ in artist_update_fields])

229
230
231
        return obj

    def to_representation(self, obj):
232
        return ChannelSerializer(obj, context=self.context).data
233
234
235
236


class ChannelSerializer(serializers.ModelSerializer):
    artist = serializers.SerializerMethodField()
237
    actor = serializers.SerializerMethodField()
238
    attributed_to = federation_serializers.APIActorSerializer()
239
    rss_url = serializers.CharField(source="get_rss_url")
240
    url = serializers.SerializerMethodField()
241
242
243

    class Meta:
        model = models.Channel
244
245
246
247
248
249
250
        fields = [
            "uuid",
            "artist",
            "attributed_to",
            "actor",
            "creation_date",
            "metadata",
251
            "rss_url",
252
            "url",
253
        ]
254
255
256

    def get_artist(self, obj):
        return music_serializers.serialize_artist_simple(obj.artist)
257

258
259
260
261
262
263
264
265
266
    def to_representation(self, obj):
        data = super().to_representation(obj)
        if self.context.get("subscriptions_count"):
            data["subscriptions_count"] = self.get_subscriptions_count(obj)
        return data

    def get_subscriptions_count(self, obj):
        return obj.actor.received_follows.exclude(approved=False).count()

267
268
269
270
271
    def get_actor(self, obj):
        if obj.attributed_to == actors.get_service_actor():
            return None
        return federation_serializers.APIActorSerializer(obj.actor).data

272
273
274
    def get_url(self, obj):
        return obj.actor.url

275
276
277
278
279
280
281
282
283
284
285

class SubscriptionSerializer(serializers.Serializer):
    approved = serializers.BooleanField(read_only=True)
    fid = serializers.URLField(read_only=True)
    uuid = serializers.UUIDField(read_only=True)
    creation_date = serializers.DateTimeField(read_only=True)

    def to_representation(self, obj):
        data = super().to_representation(obj)
        data["channel"] = ChannelSerializer(obj.target.channel).data
        return data
286
287


288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
class RssSubscribeSerializer(serializers.Serializer):
    url = serializers.URLField()


class FeedFetchException(Exception):
    pass


class BlockedFeedException(FeedFetchException):
    pass


def retrieve_feed(url):
    try:
        logger.info("Fetching RSS feed at %s", url)
        response = session.get_session().get(url)
        response.raise_for_status()
    except requests.exceptions.HTTPError as e:
        if e.response:
            raise FeedFetchException(
                "Error while fetching feed: HTTP {}".format(e.response.status_code)
            )
        raise FeedFetchException("Error while fetching feed: unknown error")
    except requests.exceptions.Timeout:
        raise FeedFetchException("Error while fetching feed: timeout")
    except requests.exceptions.ConnectionError:
        raise FeedFetchException("Error while fetching feed: connection error")
    except requests.RequestException as e:
        raise FeedFetchException("Error while fetching feed: {}".format(e))
    except Exception as e:
        raise FeedFetchException("Error while fetching feed: {}".format(e))

    return response


@transaction.atomic
324
def get_channel_from_rss_url(url, raise_exception=False):
325
326
327
328
329
330
331
332
333
334
335
    # first, check if the url is blocked
    is_valid, _ = mrf.inbox.apply({"id": url})
    if not is_valid:
        logger.warn("Feed fetch for url %s dropped by MRF", url)
        raise BlockedFeedException("This feed or domain is blocked")

    # retrieve the XML payload at the given URL
    response = retrieve_feed(url)

    parsed_feed = feedparser.parse(response.text)
    serializer = RssFeedSerializer(data=parsed_feed["feed"])
336
    if not serializer.is_valid(raise_exception=raise_exception):
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
        raise FeedFetchException("Invalid xml content: {}".format(serializer.errors))

    # second mrf check with validated data
    urls_to_check = set()
    atom_link = serializer.validated_data.get("atom_link")

    if atom_link and atom_link != url:
        urls_to_check.add(atom_link)

    if serializer.validated_data["link"] != url:
        urls_to_check.add(serializer.validated_data["link"])

    for u in urls_to_check:
        is_valid, _ = mrf.inbox.apply({"id": u})
        if not is_valid:
            logger.warn("Feed fetch for url %s dropped by MRF", u)
            raise BlockedFeedException("This feed or domain is blocked")

    # now, we're clear, we can save the data
    channel = serializer.save(rss_url=url)

    entries = parsed_feed.entries or []
    uploads = []
    track_defaults = {}
    existing_uploads = list(
        channel.library.uploads.all().select_related(
            "track__description", "track__attachment_cover"
        )
    )
366
    if parsed_feed.feed.get("rights"):
367
368
369
370
        track_defaults["copyright"] = parsed_feed.feed.rights[
            : music_models.MAX_LENGTHS["COPYRIGHT"]
        ]
    for entry in entries[: settings.PODCASTS_RSS_FEED_MAX_ITEMS]:
371
372
        logger.debug("Importing feed item %s", entry.id)
        s = RssFeedItemSerializer(data=entry)
373
        if not s.is_valid(raise_exception=raise_exception):
374
            logger.debug("Skipping invalid RSS feed item %s, ", entry, str(s.errors))
375
376
377
378
379
380
381
382
383
384
            continue
        uploads.append(
            s.save(channel, existing_uploads=existing_uploads, **track_defaults)
        )

    common_utils.on_commit(
        music_models.TrackActor.create_entries,
        library=channel.library,
        delete_existing=True,
    )
385
386
387
    if uploads:
        latest_track_date = max([upload.track.creation_date for upload in uploads])
        common_utils.update_modification_date(channel.artist, date=latest_track_date)
388
389
390
    return channel, uploads


391
392
393
394
395
# RSS related stuff
# https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
# is extremely useful


396
397
class RssFeedSerializer(serializers.Serializer):
    title = serializers.CharField()
398
    link = serializers.URLField(required=False, allow_blank=True)
399
400
401
402
403
    language = serializers.CharField(required=False, allow_blank=True)
    rights = serializers.CharField(required=False, allow_blank=True)
    itunes_explicit = serializers.BooleanField(required=False, allow_null=True)
    tags = serializers.ListField(required=False)
    atom_link = serializers.DictField(required=False)
404
    links = serializers.ListField(required=False)
405
406
407
408
409
410
411
412
413
414
415
    summary_detail = serializers.DictField(required=False)
    author_detail = serializers.DictField(required=False)
    image = serializers.DictField(required=False)

    def validate_atom_link(self, v):
        if (
            v.get("rel", "self") == "self"
            and v.get("type", "application/rss+xml") == "application/rss+xml"
        ):
            return v["href"]

416
417
418
419
420
    def validate_links(self, v):
        for link in v:
            if link.get("rel") == "self":
                return link.get("href")

421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
    def validate_summary_detail(self, v):
        content = v.get("value")
        if not content:
            return
        return {
            "content_type": v.get("type", "text/plain"),
            "text": content,
        }

    def validate_image(self, v):
        url = v.get("href")
        if url:
            return {
                "url": url,
                "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
            }

    def validate_tags(self, v):
        data = {}
        for row in v:
            if row.get("scheme") != "http://www.itunes.com/":
                continue
            term = row["term"]
            if "parent" not in data and term in categories.ITUNES_CATEGORIES:
                data["parent"] = term
            elif "child" not in data and term in categories.ITUNES_SUBCATEGORIES:
                data["child"] = term
            elif (
                term not in categories.ITUNES_SUBCATEGORIES
                and term not in categories.ITUNES_CATEGORIES
            ):
                raw_tags = term.split(" ")
                data["tags"] = []
                tag_serializer = tags_serializers.TagNameField()
                for tag in raw_tags:
                    try:
                        data["tags"].append(tag_serializer.to_internal_value(tag))
                    except Exception:
                        pass

        return data

463
464
465
466
467
468
469
470
    def validate(self, data):
        validated_data = super().validate(data)
        if not validated_data.get("link"):
            validated_data["link"] = validated_data.get("links")
        if not validated_data.get("link"):
            raise serializers.ValidationError("Missing link")
        return validated_data

471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
    @transaction.atomic
    def save(self, rss_url):
        validated_data = self.validated_data
        # because there may be redirections from the original feed URL
        real_rss_url = validated_data.get("atom_link", rss_url) or rss_url
        service_actor = actors.get_service_actor()
        author = validated_data.get("author_detail", {})
        categories = validated_data.get("tags", {})
        metadata = {
            "explicit": validated_data.get("itunes_explicit", False),
            "copyright": validated_data.get("rights"),
            "owner_name": author.get("name"),
            "owner_email": author.get("email"),
            "itunes_category": categories.get("parent"),
            "itunes_subcategory": categories.get("child"),
            "language": validated_data.get("language"),
        }
        public_url = validated_data["link"]
        existing = (
            models.Channel.objects.external_rss()
            .filter(
                Q(rss_url=real_rss_url) | Q(rss_url=rss_url) | Q(actor__url=public_url)
            )
            .first()
        )
        channel_defaults = {
            "rss_url": real_rss_url,
            "metadata": metadata,
        }
        if existing:
            artist_kwargs = {"channel": existing}
            actor_kwargs = {"channel": existing}
            actor_defaults = {"url": public_url}
        else:
            artist_kwargs = {"pk": None}
            actor_kwargs = {"pk": None}
            preferred_username = "rssfeed-{}".format(uuid.uuid4())
            actor_defaults = {
                "preferred_username": preferred_username,
                "type": "Application",
                "domain": service_actor.domain,
                "url": public_url,
                "fid": federation_utils.full_url(
                    reverse(
                        "federation:actors-detail",
                        kwargs={"preferred_username": preferred_username},
                    )
                ),
            }
            channel_defaults["attributed_to"] = service_actor

        actor_defaults["last_fetch_date"] = timezone.now()

        # create/update the artist profile
        artist, created = music_models.Artist.objects.update_or_create(
            **artist_kwargs,
            defaults={
                "attributed_to": service_actor,
529
530
531
                "name": validated_data["title"][
                    : music_models.MAX_LENGTHS["ARTIST_NAME"]
                ],
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
                "content_category": "podcast",
            },
        )

        cover = validated_data.get("image")

        if cover:
            common_utils.attach_file(artist, "attachment_cover", cover)
        tags = categories.get("tags", [])

        if tags:
            tags_models.set_tags(artist, *tags)

        summary = validated_data.get("summary_detail")
        if summary:
            common_utils.attach_content(artist, "description", summary)

        if created:
            channel_defaults["artist"] = artist

        # create/update the actor
        actor, created = federation_models.Actor.objects.update_or_create(
            **actor_kwargs, defaults=actor_defaults
        )
        if created:
            channel_defaults["actor"] = actor

        # create the library
        if not existing:
            channel_defaults["library"] = music_models.Library.objects.create(
                actor=service_actor,
                privacy_level=settings.PODCASTS_THIRD_PARTY_VISIBILITY,
                name=actor_defaults["preferred_username"],
            )

        # create/update the channel
        channel, created = models.Channel.objects.update_or_create(
            pk=existing.pk if existing else None, defaults=channel_defaults,
        )
        return channel


class ItunesDurationField(serializers.CharField):
    def to_internal_value(self, v):
        try:
            return int(v)
        except (ValueError, TypeError):
            pass
        parts = v.split(":")
        int_parts = []
        for part in parts:
            try:
                int_parts.append(int(part))
            except (ValueError, TypeError):
                raise serializers.ValidationError("Invalid duration {}".format(v))

        if len(int_parts) == 2:
            hours = 0
            minutes, seconds = int_parts
        elif len(int_parts) == 3:
            hours, minutes, seconds = int_parts
        else:
            raise serializers.ValidationError("Invalid duration {}".format(v))

        return (hours * 3600) + (minutes * 60) + seconds


class DummyField(serializers.Field):
    def to_internal_value(self, v):
        return v


def get_cached_upload(uploads, expected_track_uuid):
    for upload in uploads:
        if upload.track.uuid == expected_track_uuid:
            return upload


610
611
612
613
614
615
616
617
class PermissiveIntegerField(serializers.IntegerField):
    def to_internal_value(self, v):
        try:
            return super().to_internal_value(v)
        except serializers.ValidationError:
            return self.default


618
619
620
621
class RssFeedItemSerializer(serializers.Serializer):
    id = serializers.CharField()
    title = serializers.CharField()
    rights = serializers.CharField(required=False, allow_blank=True)
622
623
624
625
626
627
628
629
630
    itunes_season = serializers.IntegerField(
        required=False, allow_null=True, default=None
    )
    itunes_episode = PermissiveIntegerField(
        required=False, allow_null=True, default=None
    )
    itunes_duration = ItunesDurationField(
        required=False, allow_null=True, default=None, allow_blank=True
    )
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
    links = serializers.ListField()
    tags = serializers.ListField(required=False)
    summary_detail = serializers.DictField(required=False)
    published_parsed = DummyField(required=False)
    image = serializers.DictField(required=False)

    def validate_summary_detail(self, v):
        content = v.get("value")
        if not content:
            return
        return {
            "content_type": v.get("type", "text/plain"),
            "text": content,
        }

    def validate_image(self, v):
        url = v.get("href")
        if url:
            return {
                "url": url,
                "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
            }

    def validate_links(self, v):
        data = {}
        for row in v:
            if not row.get("type", "").startswith("audio/"):
                continue
            if row.get("rel") != "enclosure":
                continue
            try:
662
                size = int(row.get("length", 0) or 0) or None
663
664
665
666
            except (TypeError, ValueError):
                raise serializers.ValidationError("Invalid size")

            data["audio"] = {
667
                "mimetype": common_utils.get_audio_mimetype(row["type"]),
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
                "size": size,
                "source": row["href"],
            }

        if not data:
            raise serializers.ValidationError("No valid audio enclosure found")

        return data

    def validate_tags(self, v):
        data = {}
        for row in v:
            if row.get("scheme") != "http://www.itunes.com/":
                continue
            term = row["term"]
            raw_tags = term.split(" ")
            data["tags"] = []
            tag_serializer = tags_serializers.TagNameField()
            for tag in raw_tags:
                try:
                    data["tags"].append(tag_serializer.to_internal_value(tag))
                except Exception:
                    pass

        return data

    @transaction.atomic
    def save(self, channel, existing_uploads=[], **track_defaults):
        validated_data = self.validated_data
        categories = validated_data.get("tags", {})
        expected_uuid = uuid.uuid3(
            uuid.NAMESPACE_URL, "rss://{}-{}".format(channel.pk, validated_data["id"])
        )
        existing_upload = get_cached_upload(existing_uploads, expected_uuid)
        if existing_upload:
            existing_track = existing_upload.track
        else:
            existing_track = (
                music_models.Track.objects.filter(
                    uuid=expected_uuid, artist__channel=channel
                )
                .select_related("description", "attachment_cover")
                .first()
            )
            if existing_track:
                existing_upload = existing_track.uploads.filter(
                    library=channel.library
                ).first()

        track_defaults = track_defaults
        track_defaults.update(
            {
720
721
                "disc_number": validated_data.get("itunes_season", 1) or 1,
                "position": validated_data.get("itunes_episode", 1) or 1,
722
723
724
                "title": validated_data["title"][
                    : music_models.MAX_LENGTHS["TRACK_TITLE"]
                ],
725
726
727
728
                "artist": channel.artist,
            }
        )
        if "rights" in validated_data:
729
            track_defaults["copyright"] = validated_data["rights"][
730
731
                : music_models.MAX_LENGTHS["COPYRIGHT"]
            ]
732
733
734
735
736
737
738
739
740
741

        if "published_parsed" in validated_data:
            track_defaults["creation_date"] = datetime.datetime.fromtimestamp(
                time.mktime(validated_data["published_parsed"])
            ).replace(tzinfo=pytz.utc)

        upload_defaults = {
            "source": validated_data["links"]["audio"]["source"],
            "size": validated_data["links"]["audio"]["size"],
            "mimetype": validated_data["links"]["audio"]["mimetype"],
742
            "duration": validated_data.get("itunes_duration") or None,
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
            "import_status": "finished",
            "library": channel.library,
        }
        if existing_track:
            track_kwargs = {"pk": existing_track.pk}
            upload_kwargs = {"track": existing_track}
        else:
            track_kwargs = {"pk": None}
            track_defaults["uuid"] = expected_uuid
            upload_kwargs = {"pk": None}

        if existing_upload and existing_upload.source != upload_defaults["source"]:
            # delete existing upload, the url to the audio file has changed
            existing_upload.delete()

        # create/update the track
        track, created = music_models.Track.objects.update_or_create(
            **track_kwargs, defaults=track_defaults,
        )
        # optimisation for reducing SQL queries, because we cannot use select_related with
        # update or create, so we restore the cache by hand
        if existing_track:
            for field in ["attachment_cover", "description"]:
                cached_id_value = getattr(existing_track, "{}_id".format(field))
                new_id_value = getattr(track, "{}_id".format(field))
                if new_id_value and cached_id_value == new_id_value:
                    setattr(track, field, getattr(existing_track, field))

        cover = validated_data.get("image")

        if cover:
            common_utils.attach_file(track, "attachment_cover", cover)
        tags = categories.get("tags", [])

        if tags:
            tags_models.set_tags(track, *tags)

        summary = validated_data.get("summary_detail")
        if summary:
            common_utils.attach_content(track, "description", summary)

        if created:
            upload_defaults["track"] = track

        # create/update the upload
        upload, created = music_models.Upload.objects.update_or_create(
            **upload_kwargs, defaults=upload_defaults
        )

        return upload


Eliot Berriot's avatar
Eliot Berriot committed
795
def rfc822_date(dt):
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
    return dt.strftime("%a, %d %b %Y %H:%M:%S %z")


def rss_duration(seconds):
    if not seconds:
        return "00:00:00"
    full_hours = seconds // 3600
    full_minutes = (seconds - (full_hours * 3600)) // 60
    remaining_seconds = seconds - (full_hours * 3600) - (full_minutes * 60)
    return "{}:{}:{}".format(
        str(full_hours).zfill(2),
        str(full_minutes).zfill(2),
        str(remaining_seconds).zfill(2),
    )


def rss_serialize_item(upload):
    data = {
        "title": [{"value": upload.track.title}],
        "itunes:title": [{"value": upload.track.title}],
        "guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
Eliot Berriot's avatar
Eliot Berriot committed
817
        "pubDate": [{"value": rfc822_date(upload.creation_date)}],
818
819
820
821
822
823
824
825
        "itunes:duration": [{"value": rss_duration(upload.duration)}],
        "itunes:explicit": [{"value": "no"}],
        "itunes:episodeType": [{"value": "full"}],
        "itunes:season": [{"value": upload.track.disc_number or 1}],
        "itunes:episode": [{"value": upload.track.position or 1}],
        "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
        "enclosure": [
            {
826
                # we enforce MP3, since it's the only format supported everywhere
Agate's avatar
Agate committed
827
828
829
                "url": federation_utils.full_url(
                    upload.get_listen_url(to="mp3", download=False)
                ),
830
                "length": upload.size or 0,
831
                "type": "audio/mpeg",
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
            }
        ],
    }
    if upload.track.description:
        data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
        data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
        data["description"] = [{"value": upload.track.description.as_plain_text}]

    if upload.track.attachment_cover:
        data["itunes:image"] = [
            {"href": upload.track.attachment_cover.download_url_original}
        ]

    tagged_items = getattr(upload.track, "_prefetched_tagged_items", [])
    if tagged_items:
        data["itunes:keywords"] = [
            {"value": " ".join([ti.tag.name for ti in tagged_items])}
        ]

    return data


def rss_serialize_channel(channel):
    metadata = channel.metadata or {}
    explicit = metadata.get("explicit", False)
    copyright = metadata.get("copyright", "All rights reserved")
    owner_name = metadata.get("owner_name", channel.attributed_to.display_name)
    owner_email = metadata.get("owner_email")
    itunes_category = metadata.get("itunes_category")
    itunes_subcategory = metadata.get("itunes_subcategory")
    language = metadata.get("language")

    data = {
        "title": [{"value": channel.artist.name}],
        "copyright": [{"value": copyright}],
        "itunes:explicit": [{"value": "no" if not explicit else "yes"}],
        "itunes:author": [{"value": owner_name}],
        "itunes:owner": [{"itunes:name": [{"value": owner_name}]}],
        "itunes:type": [{"value": "episodic"}],
        "link": [{"value": channel.get_absolute_url()}],
        "atom:link": [
            {
                "href": channel.get_rss_url(),
                "rel": "self",
                "type": "application/rss+xml",
877
878
879
880
881
882
            },
            {
                "href": channel.actor.fid,
                "rel": "alternate",
                "type": "application/activity+json",
            },
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
        ],
    }
    if language:
        data["language"] = [{"value": language}]

    if owner_email:
        data["itunes:owner"][0]["itunes:email"] = [{"value": owner_email}]

    if itunes_category:
        node = {"text": itunes_category}
        if itunes_subcategory:
            node["itunes:category"] = [{"text": itunes_subcategory}]
        data["itunes:category"] = [node]

    if channel.artist.description:
        data["itunes:subtitle"] = [{"value": channel.artist.description.truncate(255)}]
        data["itunes:summary"] = [{"cdata_value": channel.artist.description.rendered}]
        data["description"] = [{"value": channel.artist.description.as_plain_text}]

    if channel.artist.attachment_cover:
        data["itunes:image"] = [
            {"href": channel.artist.attachment_cover.download_url_original}
        ]
906
907
908
909
910
    else:
        placeholder_url = federation_utils.full_url(
            static("images/podcasts-cover-placeholder.png")
        )
        data["itunes:image"] = [{"href": placeholder_url}]
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925

    tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])

    if tagged_items:
        data["itunes:keywords"] = [
            {"value": " ".join([ti.tag.name for ti in tagged_items])}
        ]

    return data


def rss_serialize_channel_full(channel, uploads):
    channel_data = rss_serialize_channel(channel)
    channel_data["item"] = [rss_serialize_item(upload) for upload in uploads]
    return {"channel": channel_data}
Eliot Berriot's avatar
Eliot Berriot committed
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944


# OPML stuff
def get_opml_outline(channel):
    return {
        "title": channel.artist.name,
        "text": channel.artist.name,
        "type": "rss",
        "xmlUrl": channel.get_rss_url(),
        "htmlUrl": channel.actor.url,
    }


def get_opml(channels, date, title):
    return {
        "version": "2.0",
        "head": [{"date": [{"value": rfc822_date(date)}], "title": [{"value": title}]}],
        "body": [{"outline": [get_opml_outline(channel) for channel in channels]}],
    }