metadata.py 23.5 KB
Newer Older
1
import base64
2
from collections.abc import Mapping
3
import datetime
4
import logging
5
import arrow
6

7
8
9
10
11
import mutagen._util
import mutagen.oggtheora
import mutagen.oggvorbis
import mutagen.flac

12
from rest_framework import serializers
13

14
15
from funkwhale_api.tags import models as tags_models

16
logger = logging.getLogger(__name__)
17
NODEFAULT = object()
18
# default title used when imported tracks miss the `Album` tag, see #122
Eliot Berriot's avatar
Eliot Berriot committed
19
UNKNOWN_ALBUM = "[Unknown Album]"
20

21
22
23
24
25

class TagNotFound(KeyError):
    pass


26
27
28
29
class UnsupportedTag(KeyError):
    pass


30
31
32
33
class ParseError(ValueError):
    pass


34
def get_id3_tag(f, k):
Eliot Berriot's avatar
Eliot Berriot committed
35
36
    if k == "pictures":
        return f.tags.getall("APIC")
37
    # First we try to grab the standard key
Eliot Berriot's avatar
Eliot Berriot committed
38
39
40
41
42
43
44
45
46
    possible_attributes = [("text", True), ("url", False)]
    for attr, select_first in possible_attributes:
        try:
            v = getattr(f.tags[k], attr)
            if select_first:
                v = v[0]
            return v
        except KeyError:
            break
47
48
        except IndexError:
            break
Eliot Berriot's avatar
Eliot Berriot committed
49
50
51
        except AttributeError:
            continue

52
    # then we fallback on parsing non standard tags
Eliot Berriot's avatar
Eliot Berriot committed
53
    all_tags = f.tags.getall("TXXX")
54
    try:
Eliot Berriot's avatar
Eliot Berriot committed
55
        matches = [t for t in all_tags if t.desc.lower() == k.lower()]
56
57
58
59
60
        return matches[0].text[0]
    except (KeyError, IndexError):
        raise TagNotFound(k)


61
62
63
def clean_id3_pictures(apic):
    pictures = []
    for p in list(apic):
Eliot Berriot's avatar
Eliot Berriot committed
64
65
66
67
68
69
70
71
        pictures.append(
            {
                "mimetype": p.mime,
                "content": p.data,
                "description": p.desc,
                "type": p.type.real,
            }
        )
72
73
74
    return pictures


75
76
def get_mp4_tag(f, k):
    if k == "pictures":
77
        return f.get("covr", [])
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
    raw_value = f.get(k, None)

    if not raw_value:
        raise TagNotFound(k)

    value = raw_value[0]
    try:
        return value.decode()
    except AttributeError:
        return value


def get_mp4_position(raw_value):
    return raw_value[0]


def clean_mp4_pictures(raw_pictures):
    pictures = []
    for p in list(raw_pictures):
        if p.imageformat == p.FORMAT_JPEG:
            mimetype = "image/jpeg"
        elif p.imageformat == p.FORMAT_PNG:
            mimetype = "image/png"
        else:
            continue
        pictures.append(
            {
                "mimetype": mimetype,
                "content": bytes(p),
                "description": "",
                "type": mutagen.id3.PictureType.COVER_FRONT,
            }
        )
    return pictures


114
def get_flac_tag(f, k):
Eliot Berriot's avatar
Eliot Berriot committed
115
    if k == "pictures":
116
        return f.pictures
117
    try:
118
        return f.get(k, [])[0]
119
120
121
122
    except (KeyError, IndexError):
        raise TagNotFound(k)


123
124
125
def clean_flac_pictures(apic):
    pictures = []
    for p in list(apic):
Eliot Berriot's avatar
Eliot Berriot committed
126
127
128
129
130
131
132
133
        pictures.append(
            {
                "mimetype": p.mime,
                "content": p.data,
                "description": p.desc,
                "type": p.type.real,
            }
        )
134
135
136
    return pictures


137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def clean_ogg_pictures(metadata_block_picture):
    pictures = []
    for b64_data in [metadata_block_picture]:

        try:
            data = base64.b64decode(b64_data)
        except (TypeError, ValueError):
            continue

        try:
            picture = mutagen.flac.Picture(data)
        except mutagen.flac.FLACError:
            continue

        pictures.append(
            {
                "mimetype": picture.mime,
                "content": picture.data,
                "description": "",
                "type": picture.type.real,
            }
        )
    return pictures


162
163
def get_mp3_recording_id(f, k):
    try:
Eliot Berriot's avatar
Eliot Berriot committed
164
165
166
        return [t for t in f.tags.getall("UFID") if "musicbrainz.org" in t.owner][
            0
        ].data.decode("utf-8")
167
168
169
    except IndexError:
        raise TagNotFound(k)

170

171
172
173
174
175
176
177
178
179
180
181
def get_mp3_comment(f, k):
    keys_to_try = ["COMM", "COMM::eng"]
    for key in keys_to_try:
        try:
            return get_id3_tag(f, key)
        except TagNotFound:
            pass

    raise TagNotFound("COMM")


182
VALIDATION = {}
183

184
CONF = {
185
186
187
    "OggOpus": {
        "getter": lambda f, k: f[k][0],
        "fields": {
188
189
            "position": {"field": "TRACKNUMBER"},
            "disc_number": {"field": "DISCNUMBER"},
190
191
            "title": {},
            "artist": {},
192
            "artists": {},
193
            "album_artist": {"field": "albumartist"},
194
            "album": {},
195
            "date": {"field": "date"},
196
197
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
198
            "musicbrainz_albumartistid": {},
199
            "mbid": {"field": "musicbrainz_trackid"},
Eliot Berriot's avatar
Eliot Berriot committed
200
201
            "license": {},
            "copyright": {},
202
            "genre": {},
203
204
205
206
            "pictures": {
                "field": "metadata_block_picture",
                "to_application": clean_ogg_pictures,
            },
207
            "comment": {"field": "comment"},
208
209
        },
    },
Eliot Berriot's avatar
Eliot Berriot committed
210
211
212
    "OggVorbis": {
        "getter": lambda f, k: f[k][0],
        "fields": {
213
214
            "position": {"field": "TRACKNUMBER"},
            "disc_number": {"field": "DISCNUMBER"},
Eliot Berriot's avatar
Eliot Berriot committed
215
216
            "title": {},
            "artist": {},
217
            "artists": {},
218
            "album_artist": {"field": "albumartist"},
Eliot Berriot's avatar
Eliot Berriot committed
219
            "album": {},
220
            "date": {"field": "date"},
Eliot Berriot's avatar
Eliot Berriot committed
221
222
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
223
            "musicbrainz_albumartistid": {},
224
            "mbid": {"field": "musicbrainz_trackid"},
Eliot Berriot's avatar
Eliot Berriot committed
225
226
            "license": {},
            "copyright": {},
227
            "genre": {},
228
229
230
231
            "pictures": {
                "field": "metadata_block_picture",
                "to_application": clean_ogg_pictures,
            },
232
            "comment": {"field": "comment"},
Eliot Berriot's avatar
Eliot Berriot committed
233
        },
EorlBruder's avatar
EorlBruder committed
234
    },
Eliot Berriot's avatar
Eliot Berriot committed
235
236
237
    "OggTheora": {
        "getter": lambda f, k: f[k][0],
        "fields": {
238
239
            "position": {"field": "TRACKNUMBER"},
            "disc_number": {"field": "DISCNUMBER"},
Eliot Berriot's avatar
Eliot Berriot committed
240
241
            "title": {},
            "artist": {},
242
            "artists": {},
Eliot Berriot's avatar
Eliot Berriot committed
243
            "album_artist": {"field": "albumartist"},
Eliot Berriot's avatar
Eliot Berriot committed
244
            "album": {},
245
            "date": {"field": "date"},
Eliot Berriot's avatar
Eliot Berriot committed
246
247
            "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
            "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
Eliot Berriot's avatar
Eliot Berriot committed
248
            "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
249
            "mbid": {"field": "MusicBrainz Track Id"},
250
251
            "license": {},
            "copyright": {},
252
            "genre": {},
253
            "comment": {"field": "comment"},
Eliot Berriot's avatar
Eliot Berriot committed
254
        },
255
    },
Philipp Wolfer's avatar
Philipp Wolfer committed
256
    "ID3": {
Eliot Berriot's avatar
Eliot Berriot committed
257
258
259
        "getter": get_id3_tag,
        "clean_pictures": clean_id3_pictures,
        "fields": {
260
261
            "position": {"field": "TRCK"},
            "disc_number": {"field": "TPOS"},
Eliot Berriot's avatar
Eliot Berriot committed
262
263
            "title": {"field": "TIT2"},
            "artist": {"field": "TPE1"},
264
            "artists": {"field": "ARTISTS"},
Eliot Berriot's avatar
Eliot Berriot committed
265
            "album_artist": {"field": "TPE2"},
Eliot Berriot's avatar
Eliot Berriot committed
266
            "album": {"field": "TALB"},
267
            "date": {"field": "TDRC"},
Eliot Berriot's avatar
Eliot Berriot committed
268
269
            "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
            "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
270
            "genre": {"field": "TCON"},
Eliot Berriot's avatar
Eliot Berriot committed
271
            "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
272
            "mbid": {"field": "UFID", "getter": get_mp3_recording_id},
Eliot Berriot's avatar
Eliot Berriot committed
273
            "pictures": {},
Eliot Berriot's avatar
Eliot Berriot committed
274
275
            "license": {"field": "WCOP"},
            "copyright": {"field": "TCOP"},
276
            "comment": {"field": "COMM", "getter": get_mp3_comment},
Eliot Berriot's avatar
Eliot Berriot committed
277
        },
278
    },
279
280
281
282
283
284
285
286
    "MP4": {
        "getter": get_mp4_tag,
        "clean_pictures": clean_mp4_pictures,
        "fields": {
            "position": {"field": "trkn", "to_application": get_mp4_position},
            "disc_number": {"field": "disk", "to_application": get_mp4_position},
            "title": {"field": "©nam"},
            "artist": {"field": "©ART"},
287
            "artists": {"field": "----:com.apple.iTunes:ARTISTS"},
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
            "album_artist": {"field": "aART"},
            "album": {"field": "©alb"},
            "date": {"field": "©day"},
            "musicbrainz_albumid": {
                "field": "----:com.apple.iTunes:MusicBrainz Album Id"
            },
            "musicbrainz_artistid": {
                "field": "----:com.apple.iTunes:MusicBrainz Artist Id"
            },
            "genre": {"field": "©gen"},
            "musicbrainz_albumartistid": {
                "field": "----:com.apple.iTunes:MusicBrainz Album Artist Id"
            },
            "mbid": {"field": "----:com.apple.iTunes:MusicBrainz Track Id"},
            "pictures": {},
            "license": {"field": "----:com.apple.iTunes:LICENSE"},
            "copyright": {"field": "cprt"},
305
            "comment": {"field": "©cmt"},
306
307
        },
    },
Eliot Berriot's avatar
Eliot Berriot committed
308
309
310
311
    "FLAC": {
        "getter": get_flac_tag,
        "clean_pictures": clean_flac_pictures,
        "fields": {
312
313
            "position": {"field": "tracknumber"},
            "disc_number": {"field": "discnumber"},
Eliot Berriot's avatar
Eliot Berriot committed
314
315
            "title": {},
            "artist": {},
316
            "artists": {},
Eliot Berriot's avatar
Eliot Berriot committed
317
            "album_artist": {"field": "albumartist"},
Eliot Berriot's avatar
Eliot Berriot committed
318
            "album": {},
319
            "date": {"field": "date"},
Eliot Berriot's avatar
Eliot Berriot committed
320
321
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
322
            "musicbrainz_albumartistid": {},
323
            "genre": {},
324
            "mbid": {"field": "musicbrainz_trackid"},
Eliot Berriot's avatar
Eliot Berriot committed
325
326
            "test": {},
            "pictures": {},
Eliot Berriot's avatar
Eliot Berriot committed
327
328
            "license": {},
            "copyright": {},
329
            "comment": {},
Eliot Berriot's avatar
Eliot Berriot committed
330
        },
331
    },
332
333
}

Philipp Wolfer's avatar
Philipp Wolfer committed
334
335
336
CONF["MP3"] = CONF["ID3"]
CONF["AIFF"] = CONF["ID3"]

Eliot Berriot's avatar
Eliot Berriot committed
337
ALL_FIELDS = [
338
    "position",
339
    "disc_number",
Eliot Berriot's avatar
Eliot Berriot committed
340
341
342
343
344
345
346
347
    "title",
    "artist",
    "album_artist",
    "album",
    "date",
    "musicbrainz_albumid",
    "musicbrainz_artistid",
    "musicbrainz_albumartistid",
348
    "mbid",
Eliot Berriot's avatar
Eliot Berriot committed
349
350
    "license",
    "copyright",
351
    "comment",
Eliot Berriot's avatar
Eliot Berriot committed
352
353
]

354

355
class Metadata(Mapping):
356
357
    def __init__(self, filething, kind=mutagen.File):
        self._file = kind(filething)
358
        if self._file is None:
359
360
            raise ValueError("Cannot parse metadata from {}".format(filething))
        self.fallback = self.load_fallback(filething, self._file)
361
362
363
364
        ft = self.get_file_type(self._file)
        try:
            self._conf = CONF[ft]
        except KeyError:
Eliot Berriot's avatar
Eliot Berriot committed
365
            raise ValueError("Unsupported format {}".format(ft))
366

367
368
369
    def get_file_type(self, f):
        return f.__class__.__name__

370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
    def load_fallback(self, filething, parent):
        """
        In some situations, such as Ogg Theora files tagged with MusicBrainz Picard,
        part of the tags are only available in the ogg vorbis comments
        """
        try:
            filething.seek(0)
        except AttributeError:
            pass
        if isinstance(parent, mutagen.oggtheora.OggTheora):
            try:
                return Metadata(filething, kind=mutagen.oggvorbis.OggVorbis)
            except (ValueError, mutagen._util.MutagenError):
                raise
                pass

386
    def get(self, key, default=NODEFAULT):
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
        try:
            return self._get_from_self(key)
        except TagNotFound:
            if not self.fallback:
                if default != NODEFAULT:
                    return default
                else:
                    raise
            else:
                return self.fallback.get(key, default=default)
        except UnsupportedTag:
            if not self.fallback:
                raise
            else:
                return self.fallback.get(key, default=default)

403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
    def all(self):
        """
        Return a dict with all support metadata fields, if they are available
        """
        final = {}
        for field in self._conf["fields"]:
            if field in ["pictures"]:
                continue
            value = self.get(field, None)
            if value is None:
                continue
            final[field] = str(value)

        return final

418
    def _get_from_self(self, key, default=NODEFAULT):
419
        try:
Eliot Berriot's avatar
Eliot Berriot committed
420
            field_conf = self._conf["fields"][key]
421
        except KeyError:
Eliot Berriot's avatar
Eliot Berriot committed
422
423
            raise UnsupportedTag("{} is not supported for this file format".format(key))
        real_key = field_conf.get("field", key)
424
        try:
Eliot Berriot's avatar
Eliot Berriot committed
425
            getter = field_conf.get("getter", self._conf["getter"])
426
            v = getter(self._file, real_key)
427
428
        except KeyError:
            if default == NODEFAULT:
429
                raise TagNotFound(real_key)
430
431
            return default

Eliot Berriot's avatar
Eliot Berriot committed
432
        converter = field_conf.get("to_application")
433
434
        if converter:
            v = converter(v)
435
436
437
        field = VALIDATION.get(key)
        if field:
            v = field.to_python(v)
438
        return v
439

440
441
442
443
444
445
446
447
    def get_picture(self, *picture_types):
        if not picture_types:
            raise ValueError("You need to request at least one picture type")
        ptypes = [
            getattr(mutagen.id3.PictureType, picture_type.upper())
            for picture_type in picture_types
        ]

448
        try:
Eliot Berriot's avatar
Eliot Berriot committed
449
            pictures = self.get("pictures")
450
451
452
        except (UnsupportedTag, TagNotFound):
            return

Eliot Berriot's avatar
Eliot Berriot committed
453
        cleaner = self._conf.get("clean_pictures", lambda v: v)
454
        pictures = cleaner(pictures)
455
456
457
458
459
460
        if not pictures:
            return
        for ptype in ptypes:
            for p in pictures:
                if p["type"] == ptype:
                    return p
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479

    def __getitem__(self, key):
        return self.get(key)

    def __len__(self):
        return 1

    def __iter__(self):
        for field in self._conf["fields"]:
            yield field


class ArtistField(serializers.Field):
    def __init__(self, *args, **kwargs):
        self.for_album = kwargs.pop("for_album", False)
        super().__init__(*args, **kwargs)

    def get_value(self, data):
        if self.for_album:
480
481
482
483
484
            keys = [
                ("artists", "artists"),
                ("names", "album_artist"),
                ("mbids", "musicbrainz_albumartistid"),
            ]
485
        else:
486
487
488
489
490
            keys = [
                ("artists", "artists"),
                ("names", "artist"),
                ("mbids", "musicbrainz_artistid"),
            ]
491
492
493
494
495
496
497
498
499

        final = {}
        for field, key in keys:
            final[field] = data.get(key, None)

        return final

    def to_internal_value(self, data):
        # we have multiple values that can be separated by various separators
Agate's avatar
Agate committed
500
        separators = [";", ","]
501
502
503
504
505
506
        # we get a list like that if tagged via musicbrainz
        # ae29aae4-abfb-4609-8f54-417b1f4d64cc; 3237b5a8-ae44-400c-aa6d-cea51f0b9074;
        raw_mbids = data["mbids"]
        used_separator = None
        mbids = [raw_mbids]
        if raw_mbids:
507
508
509
510
511
512
513
514
515
            if "/" in raw_mbids:
                # it's a featuring, we can't handle this now
                mbids = []
            else:
                for separator in separators:
                    if separator in raw_mbids:
                        used_separator = separator
                        mbids = [m.strip() for m in raw_mbids.split(separator)]
                        break
516
517
518

        # now, we split on artist names, using the same separator as the one used
        # by mbids, if any
519
520
521
522
523
524
525
526
527
528
        names = []

        if data.get("artists", None):
            for separator in separators:
                if separator in data["artists"]:
                    names = [n.strip() for n in data["artists"].split(separator)]
                    break
            if not names:
                names = [data["artists"]]
        elif used_separator and mbids:
529
530
531
532
533
534
535
536
537
538
539
540
            names = [n.strip() for n in data["names"].split(used_separator)]
        else:
            names = [data["names"]]

        final = []
        for i, name in enumerate(names):
            try:
                mbid = mbids[i]
            except IndexError:
                mbid = None
            artist = {"name": name, "mbid": mbid}
            final.append(artist)
541
542
543
544
        field = serializers.ListField(
            child=ArtistSerializer(strict=self.context.get("strict", True)),
            min_length=1,
        )
545
546
547
548
549
550
551
552
553
        return field.to_internal_value(final)


class AlbumField(serializers.Field):
    def get_value(self, data):
        return data

    def to_internal_value(self, data):
        try:
554
            title = data.get("album") or ""
555
        except TagNotFound:
556
557
            title = ""

Eliot Berriot's avatar
Eliot Berriot committed
558
        title = title.strip() or UNKNOWN_ALBUM
559
560
561
562
563
564
565
        final = {
            "title": title,
            "release_date": data.get("date", None),
            "mbid": data.get("musicbrainz_albumid", None),
        }
        artists_field = ArtistField(for_album=True)
        payload = artists_field.get_value(data)
566
567
568
569
570
        try:
            artists = artists_field.to_internal_value(payload)
        except serializers.ValidationError as e:
            artists = []
            logger.debug("Ignoring validation error on album artists: %s", e)
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
        album_serializer = AlbumSerializer(data=final)
        album_serializer.is_valid(raise_exception=True)
        album_serializer.validated_data["artists"] = artists
        return album_serializer.validated_data


class CoverDataField(serializers.Field):
    def get_value(self, data):
        return data

    def to_internal_value(self, data):
        return data.get_picture("cover_front", "other")


class PermissiveDateField(serializers.CharField):
    def to_internal_value(self, value):
        if not value:
            return None
        value = super().to_internal_value(str(value))
        ADDITIONAL_FORMATS = [
            "%Y-%d-%m %H:%M",  # deezer date format
            "%Y-%W",  # weird date format based on week number, see #718
        ]

        for date_format in ADDITIONAL_FORMATS:
            try:
                parsed = datetime.datetime.strptime(value, date_format)
            except ValueError:
                continue
            else:
                return datetime.date(parsed.year, parsed.month, parsed.day)

        try:
604
            parsed = arrow.get(str(value))
605
            return datetime.date(parsed.year, parsed.month, parsed.day)
606
        except (arrow.parser.ParserError, ValueError):
607
608
609
610
611
            pass

        return None


612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
def extract_tags_from_genre(string):
    tags = []
    delimiter = "@@@@@"
    for d in [" - ", ",", ";", "/"]:
        # Replace common tags separators by a custom delimiter
        string = string.replace(d, delimiter)

    # loop on the parts (splitting on our custom delimiter)
    for tag in string.split(delimiter):
        tag = tag.strip()
        for d in ["-"]:
            # preparation for replacement so that Pop-Rock becomes Pop Rock, then PopRock
            # (step 1, step 2 happens below)
            tag = tag.replace(d, " ")
        if not tag:
            continue
        final_tag = ""
629
        if not tags_models.TAG_REGEX.match(tag.replace(" ", "")):
630
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
662
663
            # the string contains some non words chars ($, €, etc.), right now
            # we simply skip such tags
            continue
        # concatenate the parts and uppercase them so that 'pop rock' becomes 'PopRock'
        if len(tag.split(" ")) == 1:
            # we append the tag "as is", because it doesn't contain any space
            tags.append(tag)
            continue
        for part in tag.split(" "):
            # the tag contains space, there's work to do to have consistent case
            # 'pop rock' -> 'PopRock'
            # (step 2)
            if not part:
                continue
            final_tag += part[0].upper() + part[1:]
        if final_tag:
            tags.append(final_tag)
    return tags


class TagsField(serializers.CharField):
    def get_value(self, data):
        return data

    def to_internal_value(self, data):
        try:
            value = data.get("genre") or ""
        except TagNotFound:
            return []
        value = super().to_internal_value(str(value))

        return extract_tags_from_genre(value)


664
665
666
667
668
669
670
671
672
673
674
675
class MBIDField(serializers.UUIDField):
    def __init__(self, *args, **kwargs):
        kwargs.setdefault("allow_null", True)
        kwargs.setdefault("required", False)
        super().__init__(*args, **kwargs)

    def to_internal_value(self, v):
        if v in ["", None]:
            return None
        return super().to_internal_value(v)


676
class ArtistSerializer(serializers.Serializer):
677
    name = serializers.CharField(required=False, allow_null=True, allow_blank=True)
678
    mbid = MBIDField()
679

680
681
682
683
684
685
686
687
688
    def __init__(self, *args, **kwargs):
        self.strict = kwargs.pop("strict", True)
        super().__init__(*args, **kwargs)

    def validate_name(self, v):
        if self.strict and not v:
            raise serializers.ValidationError("This field is required.")
        return v

689
690

class AlbumSerializer(serializers.Serializer):
691
    title = serializers.CharField(required=False, allow_null=True)
692
    mbid = MBIDField()
693
694
695
    release_date = PermissiveDateField(
        required=False, allow_null=True, allow_blank=True
    )
696

697
698
699
700
701
    def validate_title(self, v):
        if self.context.get("strict", True) and not v:
            raise serializers.ValidationError("This field is required.")
        return v

702

703
704
705
706
707
708
def get_valid_position(v):
    if v <= 0:
        v = 1
    return v


709
710
711
712
713
714
715
class PositionField(serializers.CharField):
    def to_internal_value(self, v):
        v = super().to_internal_value(v)
        if not v:
            return v

        try:
716
            return get_valid_position(int(v))
717
718
719
720
721
        except ValueError:
            # maybe the position is of the form "1/4"
            pass

        try:
722
            return get_valid_position(int(v.split("/")[0]))
723
        except (ValueError, AttributeError, IndexError):
724
            return
725
726


727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
class DescriptionField(serializers.CharField):
    def get_value(self, data):
        return data

    def to_internal_value(self, data):
        try:
            value = data.get("comment") or None
        except TagNotFound:
            return None
        if not value:
            return None
        value = super().to_internal_value(value)
        return {"text": value, "content_type": "text/plain"}


742
class TrackMetadataSerializer(serializers.Serializer):
743
    title = serializers.CharField(required=False, allow_null=True)
744
745
746
747
748
    position = PositionField(allow_blank=True, allow_null=True, required=False)
    disc_number = PositionField(allow_blank=True, allow_null=True, required=False)
    copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
    license = serializers.CharField(allow_blank=True, allow_null=True, required=False)
    mbid = MBIDField()
749
    tags = TagsField(allow_blank=True, allow_null=True, required=False)
750
    description = DescriptionField(allow_null=True, allow_blank=True, required=False)
751
752
753
754

    album = AlbumField()
    artists = ArtistField()
    cover_data = CoverDataField()
755

756
757
    remove_blank_null_fields = [
        "copyright",
758
        "description",
759
760
761
762
        "license",
        "position",
        "disc_number",
        "mbid",
763
        "tags",
764
765
    ]

766
767
768
769
770
    def validate_title(self, v):
        if self.context.get("strict", True) and not v:
            raise serializers.ValidationError("This field is required.")
        return v

771
772
773
774
775
776
777
    def validate(self, validated_data):
        validated_data = super().validate(validated_data)
        for field in self.remove_blank_null_fields:
            try:
                v = validated_data[field]
            except KeyError:
                continue
778
            if v in ["", None, []]:
779
                validated_data.pop(field)
780
        validated_data["album"]["cover_data"] = validated_data.pop("cover_data", None)
781
782
        return validated_data

783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799

class FakeMetadata(Mapping):
    def __init__(self, data, picture=None):
        self.data = data
        self.picture = None

    def __getitem__(self, key):
        return self.data[key]

    def __len__(self):
        return len(self.data)

    def __iter__(self):
        yield from self.data

    def get_picture(self, *args):
        return self.picture