Merge branch 'attachments' into 'develop'
Attachments See merge request funkwhale/funkwhale!951
This commit is contained in:
		
						commit
						32c0afab4f
					
				| 
						 | 
					@ -28,6 +28,7 @@ router.register(
 | 
				
			||||||
    r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
 | 
					    r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
router.register(r"mutations", common_views.MutationViewSet, "mutations")
 | 
					router.register(r"mutations", common_views.MutationViewSet, "mutations")
 | 
				
			||||||
 | 
					router.register(r"attachments", common_views.AttachmentViewSet, "attachments")
 | 
				
			||||||
v1_patterns = router.urls
 | 
					v1_patterns = router.urls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
subsonic_router = routers.SimpleRouter(trailing_slash=False)
 | 
					subsonic_router = routers.SimpleRouter(trailing_slash=False)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -392,6 +392,11 @@ MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media")))
 | 
				
			||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
 | 
					# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
 | 
				
			||||||
MEDIA_URL = env("MEDIA_URL", default="/media/")
 | 
					MEDIA_URL = env("MEDIA_URL", default="/media/")
 | 
				
			||||||
FILE_UPLOAD_PERMISSIONS = 0o644
 | 
					FILE_UPLOAD_PERMISSIONS = 0o644
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ATTACHMENTS_UNATTACHED_PRUNE_DELAY = env.int(
 | 
				
			||||||
 | 
					    "ATTACHMENTS_UNATTACHED_PRUNE_DELAY", default=3600 * 24
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# URL Configuration
 | 
					# URL Configuration
 | 
				
			||||||
# ------------------------------------------------------------------------------
 | 
					# ------------------------------------------------------------------------------
 | 
				
			||||||
ROOT_URLCONF = "config.urls"
 | 
					ROOT_URLCONF = "config.urls"
 | 
				
			||||||
| 
						 | 
					@ -558,6 +563,11 @@ CELERY_BROKER_URL = env(
 | 
				
			||||||
CELERY_TASK_DEFAULT_RATE_LIMIT = 1
 | 
					CELERY_TASK_DEFAULT_RATE_LIMIT = 1
 | 
				
			||||||
CELERY_TASK_TIME_LIMIT = 300
 | 
					CELERY_TASK_TIME_LIMIT = 300
 | 
				
			||||||
CELERY_BEAT_SCHEDULE = {
 | 
					CELERY_BEAT_SCHEDULE = {
 | 
				
			||||||
 | 
					    "common.prune_unattached_attachments": {
 | 
				
			||||||
 | 
					        "task": "common.prune_unattached_attachments",
 | 
				
			||||||
 | 
					        "schedule": crontab(minute="0", hour="*"),
 | 
				
			||||||
 | 
					        "options": {"expires": 60 * 60},
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "federation.clean_music_cache": {
 | 
					    "federation.clean_music_cache": {
 | 
				
			||||||
        "task": "federation.clean_music_cache",
 | 
					        "task": "federation.clean_music_cache",
 | 
				
			||||||
        "schedule": crontab(minute="0", hour="*/2"),
 | 
					        "schedule": crontab(minute="0", hour="*/2"),
 | 
				
			||||||
| 
						 | 
					@ -856,6 +866,7 @@ ACCOUNT_USERNAME_BLACKLIST = [
 | 
				
			||||||
] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
 | 
					] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
 | 
					EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
 | 
				
			||||||
 | 
					EXTERNAL_REQUESTS_TIMEOUT = env.int("EXTERNAL_REQUESTS_TIMEOUT", default=5)
 | 
				
			||||||
# XXX: deprecated, see #186
 | 
					# XXX: deprecated, see #186
 | 
				
			||||||
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
 | 
					API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -878,7 +889,11 @@ VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = {
 | 
				
			||||||
        ("square_crop", "crop__400x400"),
 | 
					        ("square_crop", "crop__400x400"),
 | 
				
			||||||
        ("medium_square_crop", "crop__200x200"),
 | 
					        ("medium_square_crop", "crop__200x200"),
 | 
				
			||||||
        ("small_square_crop", "crop__50x50"),
 | 
					        ("small_square_crop", "crop__50x50"),
 | 
				
			||||||
    ]
 | 
					    ],
 | 
				
			||||||
 | 
					    "attachment_square": [
 | 
				
			||||||
 | 
					        ("original", "url"),
 | 
				
			||||||
 | 
					        ("medium_square_crop", "crop__200x200"),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
VERSATILEIMAGEFIELD_SETTINGS = {"create_images_on_demand": False}
 | 
					VERSATILEIMAGEFIELD_SETTINGS = {"create_images_on_demand": False}
 | 
				
			||||||
RSA_KEY_SIZE = 2048
 | 
					RSA_KEY_SIZE = 2048
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,3 +45,20 @@ class MutationAdmin(ModelAdmin):
 | 
				
			||||||
    search_fields = ["created_by__preferred_username"]
 | 
					    search_fields = ["created_by__preferred_username"]
 | 
				
			||||||
    list_filter = ["type", "is_approved", "is_applied"]
 | 
					    list_filter = ["type", "is_approved", "is_applied"]
 | 
				
			||||||
    actions = [apply]
 | 
					    actions = [apply]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@register(models.Attachment)
 | 
				
			||||||
 | 
					class AttachmentAdmin(ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = [
 | 
				
			||||||
 | 
					        "uuid",
 | 
				
			||||||
 | 
					        "actor",
 | 
				
			||||||
 | 
					        "url",
 | 
				
			||||||
 | 
					        "file",
 | 
				
			||||||
 | 
					        "size",
 | 
				
			||||||
 | 
					        "mimetype",
 | 
				
			||||||
 | 
					        "creation_date",
 | 
				
			||||||
 | 
					        "last_fetch_date",
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    list_select_related = True
 | 
				
			||||||
 | 
					    search_fields = ["actor__domain__name"]
 | 
				
			||||||
 | 
					    list_filter = ["mimetype"]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,3 +23,14 @@ class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        self.target = extracted
 | 
					        self.target = extracted
 | 
				
			||||||
        self.save()
 | 
					        self.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@registry.register
 | 
				
			||||||
 | 
					class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
 | 
				
			||||||
 | 
					    url = factory.Faker("federation_url")
 | 
				
			||||||
 | 
					    uuid = factory.Faker("uuid4")
 | 
				
			||||||
 | 
					    actor = factory.SubFactory(federation_factories.ActorFactory)
 | 
				
			||||||
 | 
					    file = factory.django.ImageField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = "common.Attachment"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,5 @@
 | 
				
			||||||
import html
 | 
					import html
 | 
				
			||||||
import io
 | 
					import io
 | 
				
			||||||
import requests
 | 
					 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
import xml.sax.saxutils
 | 
					import xml.sax.saxutils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +10,7 @@ from django import urls
 | 
				
			||||||
from rest_framework import views
 | 
					from rest_framework import views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import preferences
 | 
					from . import preferences
 | 
				
			||||||
 | 
					from . import session
 | 
				
			||||||
from . import throttling
 | 
					from . import throttling
 | 
				
			||||||
from . import utils
 | 
					from . import utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -76,10 +76,7 @@ def get_spa_html(spa_url):
 | 
				
			||||||
    if cached:
 | 
					    if cached:
 | 
				
			||||||
        return cached
 | 
					        return cached
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    response = requests.get(
 | 
					    response = session.get_session().get(utils.join_url(spa_url, "index.html"),)
 | 
				
			||||||
        utils.join_url(spa_url, "index.html"),
 | 
					 | 
				
			||||||
        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    response.raise_for_status()
 | 
					    response.raise_for_status()
 | 
				
			||||||
    content = response.text
 | 
					    content = response.text
 | 
				
			||||||
    caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION)
 | 
					    caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,35 @@
 | 
				
			||||||
 | 
					# Generated by Django 2.2.6 on 2019-11-11 13:38
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.contrib.postgres.fields.jsonb
 | 
				
			||||||
 | 
					import django.core.serializers.json
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					import django.utils.timezone
 | 
				
			||||||
 | 
					import funkwhale_api.common.models
 | 
				
			||||||
 | 
					import funkwhale_api.common.validators
 | 
				
			||||||
 | 
					import uuid
 | 
				
			||||||
 | 
					import versatileimagefield.fields
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('common', '0003_cit_extension'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='Attachment',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('url', models.URLField(max_length=500, unique=True, null=True)),
 | 
				
			||||||
 | 
					                ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
 | 
				
			||||||
 | 
					                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
 | 
				
			||||||
 | 
					                ('last_fetch_date', models.DateTimeField(blank=True, null=True)),
 | 
				
			||||||
 | 
					                ('size', models.IntegerField(blank=True, null=True)),
 | 
				
			||||||
 | 
					                ('mimetype', models.CharField(blank=True, max_length=200, null=True)),
 | 
				
			||||||
 | 
					                ('file', versatileimagefield.fields.VersatileImageField(max_length=255, upload_to=funkwhale_api.common.models.get_file_path, validators=[funkwhale_api.common.validators.ImageDimensionsValidator(min_height=50, min_width=50), funkwhale_api.common.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg'], max_size=5242880)])),
 | 
				
			||||||
 | 
					                ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='federation.Actor', null=True)),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,6 @@
 | 
				
			||||||
import uuid
 | 
					import uuid
 | 
				
			||||||
 | 
					import magic
 | 
				
			||||||
 | 
					import mimetypes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.postgres.fields import JSONField
 | 
					from django.contrib.postgres.fields import JSONField
 | 
				
			||||||
from django.contrib.contenttypes.fields import GenericForeignKey
 | 
					from django.contrib.contenttypes.fields import GenericForeignKey
 | 
				
			||||||
| 
						 | 
					@ -9,11 +11,18 @@ from django.db import connections, models, transaction
 | 
				
			||||||
from django.db.models import Lookup
 | 
					from django.db.models import Lookup
 | 
				
			||||||
from django.db.models.fields import Field
 | 
					from django.db.models.fields import Field
 | 
				
			||||||
from django.db.models.sql.compiler import SQLCompiler
 | 
					from django.db.models.sql.compiler import SQLCompiler
 | 
				
			||||||
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from versatileimagefield.fields import VersatileImageField
 | 
				
			||||||
 | 
					from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.federation import utils as federation_utils
 | 
					from funkwhale_api.federation import utils as federation_utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import utils
 | 
				
			||||||
 | 
					from . import validators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Field.register_lookup
 | 
					@Field.register_lookup
 | 
				
			||||||
class NotEqual(Lookup):
 | 
					class NotEqual(Lookup):
 | 
				
			||||||
| 
						 | 
					@ -150,3 +159,102 @@ class Mutation(models.Model):
 | 
				
			||||||
        self.applied_date = timezone.now()
 | 
					        self.applied_date = timezone.now()
 | 
				
			||||||
        self.save(update_fields=["is_applied", "applied_date", "previous_state"])
 | 
					        self.save(update_fields=["is_applied", "applied_date", "previous_state"])
 | 
				
			||||||
        return previous_state
 | 
					        return previous_state
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_file_path(instance, filename):
 | 
				
			||||||
 | 
					    return utils.ChunkedPath("attachments")(instance, filename)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AttachmentQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
					    def attached(self, include=True):
 | 
				
			||||||
 | 
					        related_fields = ["covered_album"]
 | 
				
			||||||
 | 
					        query = None
 | 
				
			||||||
 | 
					        for field in related_fields:
 | 
				
			||||||
 | 
					            field_query = ~models.Q(**{field: None})
 | 
				
			||||||
 | 
					            query = query | field_query if query else field_query
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if include is False:
 | 
				
			||||||
 | 
					            query = ~query
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return self.filter(query)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Attachment(models.Model):
 | 
				
			||||||
 | 
					    # Remote URL where the attachment can be fetched
 | 
				
			||||||
 | 
					    url = models.URLField(max_length=500, unique=True, null=True)
 | 
				
			||||||
 | 
					    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
 | 
				
			||||||
 | 
					    # Actor associated with the attachment
 | 
				
			||||||
 | 
					    actor = models.ForeignKey(
 | 
				
			||||||
 | 
					        "federation.Actor",
 | 
				
			||||||
 | 
					        related_name="attachments",
 | 
				
			||||||
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    creation_date = models.DateTimeField(default=timezone.now)
 | 
				
			||||||
 | 
					    last_fetch_date = models.DateTimeField(null=True, blank=True)
 | 
				
			||||||
 | 
					    # File size
 | 
				
			||||||
 | 
					    size = models.IntegerField(null=True, blank=True)
 | 
				
			||||||
 | 
					    mimetype = models.CharField(null=True, blank=True, max_length=200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    file = VersatileImageField(
 | 
				
			||||||
 | 
					        upload_to=get_file_path,
 | 
				
			||||||
 | 
					        max_length=255,
 | 
				
			||||||
 | 
					        validators=[
 | 
				
			||||||
 | 
					            validators.ImageDimensionsValidator(min_width=50, min_height=50),
 | 
				
			||||||
 | 
					            validators.FileValidator(
 | 
				
			||||||
 | 
					                allowed_extensions=["png", "jpg", "jpeg"], max_size=1024 * 1024 * 5,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = AttachmentQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, **kwargs):
 | 
				
			||||||
 | 
					        if self.file and not self.size:
 | 
				
			||||||
 | 
					            self.size = self.file.size
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.file and not self.mimetype:
 | 
				
			||||||
 | 
					            self.mimetype = self.guess_mimetype()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return super().save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_local(self):
 | 
				
			||||||
 | 
					        return federation_utils.is_local(self.fid)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def guess_mimetype(self):
 | 
				
			||||||
 | 
					        f = self.file
 | 
				
			||||||
 | 
					        b = min(1000000, f.size)
 | 
				
			||||||
 | 
					        t = magic.from_buffer(f.read(b), mime=True)
 | 
				
			||||||
 | 
					        if not t.startswith("image/"):
 | 
				
			||||||
 | 
					            # failure, we try guessing by extension
 | 
				
			||||||
 | 
					            mt, _ = mimetypes.guess_type(f.name)
 | 
				
			||||||
 | 
					            if mt:
 | 
				
			||||||
 | 
					                t = mt
 | 
				
			||||||
 | 
					        return t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def download_url_original(self):
 | 
				
			||||||
 | 
					        if self.file:
 | 
				
			||||||
 | 
					            return federation_utils.full_url(self.file.url)
 | 
				
			||||||
 | 
					        proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
 | 
				
			||||||
 | 
					        return federation_utils.full_url(proxy_url + "?next=original")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def download_url_medium_square_crop(self):
 | 
				
			||||||
 | 
					        if self.file:
 | 
				
			||||||
 | 
					            return federation_utils.full_url(self.file.crop["200x200"].url)
 | 
				
			||||||
 | 
					        proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
 | 
				
			||||||
 | 
					        return federation_utils.full_url(proxy_url + "?next=medium_square_crop")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(models.signals.post_save, sender=Attachment)
 | 
				
			||||||
 | 
					def warm_attachment_thumbnails(sender, instance, **kwargs):
 | 
				
			||||||
 | 
					    if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS:
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    warmer = VersatileImageFieldWarmer(
 | 
				
			||||||
 | 
					        instance_or_queryset=instance,
 | 
				
			||||||
 | 
					        rendition_key_set="attachment_square",
 | 
				
			||||||
 | 
					        image_attr="file",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    num_created, failed_to_create = warmer.warm()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,11 +4,16 @@ Compute different sizes of image used for Album covers and User avatars
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 | 
					from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from funkwhale_api.common.models import Attachment
 | 
				
			||||||
from funkwhale_api.music.models import Album
 | 
					from funkwhale_api.music.models import Album
 | 
				
			||||||
from funkwhale_api.users.models import User
 | 
					from funkwhale_api.users.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
MODELS = [(Album, "cover", "square"), (User, "avatar", "square")]
 | 
					MODELS = [
 | 
				
			||||||
 | 
					    (Album, "cover", "square"),
 | 
				
			||||||
 | 
					    (User, "avatar", "square"),
 | 
				
			||||||
 | 
					    (Attachment, "file", "attachment_square"),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def main(command, **kwargs):
 | 
					def main(command, **kwargs):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -272,3 +272,38 @@ class APIMutationSerializer(serializers.ModelSerializer):
 | 
				
			||||||
        if value not in self.context["registry"]:
 | 
					        if value not in self.context["registry"]:
 | 
				
			||||||
            raise serializers.ValidationError("Invalid mutation type {}".format(value))
 | 
					            raise serializers.ValidationError("Invalid mutation type {}".format(value))
 | 
				
			||||||
        return value
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AttachmentSerializer(serializers.Serializer):
 | 
				
			||||||
 | 
					    uuid = serializers.UUIDField(read_only=True)
 | 
				
			||||||
 | 
					    size = serializers.IntegerField(read_only=True)
 | 
				
			||||||
 | 
					    mimetype = serializers.CharField(read_only=True)
 | 
				
			||||||
 | 
					    creation_date = serializers.DateTimeField(read_only=True)
 | 
				
			||||||
 | 
					    file = StripExifImageField(write_only=True)
 | 
				
			||||||
 | 
					    urls = serializers.SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_urls(self, o):
 | 
				
			||||||
 | 
					        urls = {}
 | 
				
			||||||
 | 
					        urls["source"] = o.url
 | 
				
			||||||
 | 
					        urls["original"] = o.download_url_original
 | 
				
			||||||
 | 
					        urls["medium_square_crop"] = o.download_url_medium_square_crop
 | 
				
			||||||
 | 
					        return urls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_representation(self, o):
 | 
				
			||||||
 | 
					        repr = super().to_representation(o)
 | 
				
			||||||
 | 
					        # XXX: BACKWARD COMPATIBILITY
 | 
				
			||||||
 | 
					        # having the attachment urls in a nested JSON obj is better,
 | 
				
			||||||
 | 
					        # but we can't do this without breaking clients
 | 
				
			||||||
 | 
					        # So we extract the urls and include these in the parent payload
 | 
				
			||||||
 | 
					        repr.update({k: v for k, v in repr["urls"].items() if k != "source"})
 | 
				
			||||||
 | 
					        # also, our legacy images had lots of variations (400x400, 200x200, 50x50)
 | 
				
			||||||
 | 
					        # but we removed some of these, so we emulate these by hand (by redirecting)
 | 
				
			||||||
 | 
					        # to actual, existing attachment variations
 | 
				
			||||||
 | 
					        repr["square_crop"] = repr["medium_square_crop"]
 | 
				
			||||||
 | 
					        repr["small_square_crop"] = repr["medium_square_crop"]
 | 
				
			||||||
 | 
					        return repr
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create(self, validated_data):
 | 
				
			||||||
 | 
					        return models.Attachment.objects.create(
 | 
				
			||||||
 | 
					            file=validated_data["file"], actor=validated_data["actor"]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,13 @@ from django.conf import settings
 | 
				
			||||||
import funkwhale_api
 | 
					import funkwhale_api
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FunkwhaleSession(requests.Session):
 | 
				
			||||||
 | 
					    def request(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        kwargs.setdefault("verify", settings.EXTERNAL_REQUESTS_VERIFY_SSL)
 | 
				
			||||||
 | 
					        kwargs.setdefault("timeout", settings.EXTERNAL_REQUESTS_TIMEOUT)
 | 
				
			||||||
 | 
					        return super().request(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_user_agent():
 | 
					def get_user_agent():
 | 
				
			||||||
    return "python-requests (funkwhale/{}; +{})".format(
 | 
					    return "python-requests (funkwhale/{}; +{})".format(
 | 
				
			||||||
        funkwhale_api.__version__, settings.FUNKWHALE_URL
 | 
					        funkwhale_api.__version__, settings.FUNKWHALE_URL
 | 
				
			||||||
| 
						 | 
					@ -11,6 +18,6 @@ def get_user_agent():
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_session():
 | 
					def get_session():
 | 
				
			||||||
    s = requests.Session()
 | 
					    s = FunkwhaleSession()
 | 
				
			||||||
    s.headers["User-Agent"] = get_user_agent()
 | 
					    s.headers["User-Agent"] = get_user_agent()
 | 
				
			||||||
    return s
 | 
					    return s
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,23 @@
 | 
				
			||||||
 | 
					import datetime
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import tempfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.core.files import File
 | 
				
			||||||
from django.db import transaction
 | 
					from django.db import transaction
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.common import channels
 | 
					from funkwhale_api.common import channels
 | 
				
			||||||
from funkwhale_api.taskapp import celery
 | 
					from funkwhale_api.taskapp import celery
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import models
 | 
					from . import models
 | 
				
			||||||
from . import serializers
 | 
					from . import serializers
 | 
				
			||||||
 | 
					from . import session
 | 
				
			||||||
from . import signals
 | 
					from . import signals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@celery.app.task(name="common.apply_mutation")
 | 
					@celery.app.task(name="common.apply_mutation")
 | 
				
			||||||
@transaction.atomic
 | 
					@transaction.atomic
 | 
				
			||||||
| 
						 | 
					@ -57,3 +66,35 @@ def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwar
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def fetch_remote_attachment(attachment, filename=None, save=True):
 | 
				
			||||||
 | 
					    if attachment.file:
 | 
				
			||||||
 | 
					        # already there, no need to fetch
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    s = session.get_session()
 | 
				
			||||||
 | 
					    attachment.last_fetch_date = timezone.now()
 | 
				
			||||||
 | 
					    with tempfile.TemporaryFile() as tf:
 | 
				
			||||||
 | 
					        with s.get(attachment.url, timeout=5, stream=True) as r:
 | 
				
			||||||
 | 
					            for chunk in r.iter_content():
 | 
				
			||||||
 | 
					                tf.write(chunk)
 | 
				
			||||||
 | 
					            tf.seek(0)
 | 
				
			||||||
 | 
					            attachment.file.save(
 | 
				
			||||||
 | 
					                filename or attachment.url.split("/")[-1], File(tf), save=save
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@celery.app.task(name="common.prune_unattached_attachments")
 | 
				
			||||||
 | 
					def prune_unattached_attachments():
 | 
				
			||||||
 | 
					    limit = timezone.now() - datetime.timedelta(
 | 
				
			||||||
 | 
					        seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    candidates = models.Attachment.objects.attached(False).filter(
 | 
				
			||||||
 | 
					        creation_date__lte=limit
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    total = candidates.count()
 | 
				
			||||||
 | 
					    logger.info("Deleting %s unattached attachments…", total)
 | 
				
			||||||
 | 
					    result = candidates.delete()
 | 
				
			||||||
 | 
					    logger.info("Deletion done: %s", result)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,8 @@ from rest_framework import response
 | 
				
			||||||
from rest_framework import views
 | 
					from rest_framework import views
 | 
				
			||||||
from rest_framework import viewsets
 | 
					from rest_framework import viewsets
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from funkwhale_api.users.oauth import permissions as oauth_permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import filters
 | 
					from . import filters
 | 
				
			||||||
from . import models
 | 
					from . import models
 | 
				
			||||||
from . import mutations
 | 
					from . import mutations
 | 
				
			||||||
| 
						 | 
					@ -140,3 +142,40 @@ class RateLimitView(views.APIView):
 | 
				
			||||||
            "scopes": throttling.get_status(ident, time.time()),
 | 
					            "scopes": throttling.get_status(ident, time.time()),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return response.Response(data, status=200)
 | 
					        return response.Response(data, status=200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AttachmentViewSet(
 | 
				
			||||||
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
 | 
					    mixins.CreateModelMixin,
 | 
				
			||||||
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    viewsets.GenericViewSet,
 | 
				
			||||||
 | 
					):
 | 
				
			||||||
 | 
					    lookup_field = "uuid"
 | 
				
			||||||
 | 
					    queryset = models.Attachment.objects.all()
 | 
				
			||||||
 | 
					    serializer_class = serializers.AttachmentSerializer
 | 
				
			||||||
 | 
					    permission_classes = [oauth_permissions.ScopePermission]
 | 
				
			||||||
 | 
					    required_scope = "libraries"
 | 
				
			||||||
 | 
					    anonymous_policy = "setting"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @action(detail=True, methods=["get"])
 | 
				
			||||||
 | 
					    @transaction.atomic
 | 
				
			||||||
 | 
					    def proxy(self, request, *args, **kwargs):
 | 
				
			||||||
 | 
					        instance = self.get_object()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        size = request.GET.get("next", "original").lower()
 | 
				
			||||||
 | 
					        if size not in ["original", "medium_square_crop"]:
 | 
				
			||||||
 | 
					            size = "original"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        tasks.fetch_remote_attachment(instance)
 | 
				
			||||||
 | 
					        data = self.serializer_class(instance).data
 | 
				
			||||||
 | 
					        redirect = response.Response(status=302)
 | 
				
			||||||
 | 
					        redirect["Location"] = data["urls"][size]
 | 
				
			||||||
 | 
					        return redirect
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def perform_create(self, serializer):
 | 
				
			||||||
 | 
					        return serializer.save(actor=self.request.user.actor)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def perform_destroy(self, instance):
 | 
				
			||||||
 | 
					        if instance.actor is None or instance.actor != self.request.user.actor:
 | 
				
			||||||
 | 
					            raise exceptions.PermissionDenied()
 | 
				
			||||||
 | 
					        instance.delete()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -54,7 +54,9 @@ class TrackFavoriteViewSet(
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        tracks = Track.objects.with_playable_uploads(
 | 
					        tracks = Track.objects.with_playable_uploads(
 | 
				
			||||||
            music_utils.get_actor_from_request(self.request)
 | 
					            music_utils.get_actor_from_request(self.request)
 | 
				
			||||||
        ).select_related("artist", "album__artist", "attributed_to")
 | 
					        ).select_related(
 | 
				
			||||||
 | 
					            "artist", "album__artist", "attributed_to", "album__attachment_cover"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
 | 
					        queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
 | 
				
			||||||
        return queryset
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,10 +14,7 @@ logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_actor_data(actor_url):
 | 
					def get_actor_data(actor_url):
 | 
				
			||||||
    response = session.get_session().get(
 | 
					    response = session.get_session().get(
 | 
				
			||||||
        actor_url,
 | 
					        actor_url, headers={"Accept": "application/activity+json"},
 | 
				
			||||||
        timeout=5,
 | 
					 | 
				
			||||||
        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
        headers={"Accept": "application/activity+json"},
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    response.raise_for_status()
 | 
					    response.raise_for_status()
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,4 @@
 | 
				
			||||||
import requests
 | 
					import requests
 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.common import session
 | 
					from funkwhale_api.common import session
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,8 +11,6 @@ def get_library_data(library_url, actor):
 | 
				
			||||||
        response = session.get_session().get(
 | 
					        response = session.get_session().get(
 | 
				
			||||||
            library_url,
 | 
					            library_url,
 | 
				
			||||||
            auth=auth,
 | 
					            auth=auth,
 | 
				
			||||||
            timeout=5,
 | 
					 | 
				
			||||||
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
            headers={"Content-Type": "application/activity+json"},
 | 
					            headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
    except requests.ConnectionError:
 | 
					    except requests.ConnectionError:
 | 
				
			||||||
| 
						 | 
					@ -35,11 +32,7 @@ def get_library_data(library_url, actor):
 | 
				
			||||||
def get_library_page(library, page_url, actor):
 | 
					def get_library_page(library, page_url, actor):
 | 
				
			||||||
    auth = signing.get_auth(actor.private_key, actor.private_key_id)
 | 
					    auth = signing.get_auth(actor.private_key, actor.private_key_id)
 | 
				
			||||||
    response = session.get_session().get(
 | 
					    response = session.get_session().get(
 | 
				
			||||||
        page_url,
 | 
					        page_url, auth=auth, headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
        auth=auth,
 | 
					 | 
				
			||||||
        timeout=5,
 | 
					 | 
				
			||||||
        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
        headers={"Content-Type": "application/activity+json"},
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    serializer = serializers.CollectionPageSerializer(
 | 
					    serializer = serializers.CollectionPageSerializer(
 | 
				
			||||||
        data=response.json(),
 | 
					        data=response.json(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -541,7 +541,6 @@ class LibraryTrack(models.Model):
 | 
				
			||||||
            auth=auth,
 | 
					            auth=auth,
 | 
				
			||||||
            stream=True,
 | 
					            stream=True,
 | 
				
			||||||
            timeout=20,
 | 
					            timeout=20,
 | 
				
			||||||
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
            headers={"Content-Type": "application/activity+json"},
 | 
					            headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        with remote_response as r:
 | 
					        with remote_response as r:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -824,8 +824,8 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_tags_repr(self, instance):
 | 
					    def get_tags_repr(self, instance):
 | 
				
			||||||
        return [
 | 
					        return [
 | 
				
			||||||
            {"type": "Hashtag", "name": "#{}".format(tag)}
 | 
					            {"type": "Hashtag", "name": "#{}".format(item.tag.name)}
 | 
				
			||||||
            for tag in sorted(instance.tagged_items.values_list("tag__name", flat=True))
 | 
					            for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -902,12 +902,11 @@ class AlbumSerializer(MusicEntitySerializer):
 | 
				
			||||||
            else None,
 | 
					            else None,
 | 
				
			||||||
            "tag": self.get_tags_repr(instance),
 | 
					            "tag": self.get_tags_repr(instance),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if instance.cover:
 | 
					        if instance.attachment_cover:
 | 
				
			||||||
            d["cover"] = {
 | 
					            d["cover"] = {
 | 
				
			||||||
                "type": "Link",
 | 
					                "type": "Link",
 | 
				
			||||||
                "href": utils.full_url(instance.cover.url),
 | 
					                "href": instance.attachment_cover.download_url_original,
 | 
				
			||||||
                "mediaType": mimetypes.guess_type(instance.cover_path)[0]
 | 
					                "mediaType": instance.attachment_cover.mimetype or "image/jpeg",
 | 
				
			||||||
                or "image/jpeg",
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        if self.context.get("include_ap_context", self.parent is None):
 | 
					        if self.context.get("include_ap_context", self.parent is None):
 | 
				
			||||||
            d["@context"] = jsonld.get_default_context()
 | 
					            d["@context"] = jsonld.get_default_context()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -88,7 +88,7 @@ def dispatch_inbox(activity, call_handlers=True):
 | 
				
			||||||
        context={
 | 
					        context={
 | 
				
			||||||
            "activity": activity,
 | 
					            "activity": activity,
 | 
				
			||||||
            "actor": activity.actor,
 | 
					            "actor": activity.actor,
 | 
				
			||||||
            "inbox_items": activity.inbox_items.filter(is_read=False),
 | 
					            "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        call_handlers=call_handlers,
 | 
					        call_handlers=call_handlers,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
| 
						 | 
					@ -142,8 +142,6 @@ def deliver_to_remote(delivery):
 | 
				
			||||||
            auth=auth,
 | 
					            auth=auth,
 | 
				
			||||||
            json=delivery.activity.payload,
 | 
					            json=delivery.activity.payload,
 | 
				
			||||||
            url=delivery.inbox_url,
 | 
					            url=delivery.inbox_url,
 | 
				
			||||||
            timeout=5,
 | 
					 | 
				
			||||||
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
            headers={"Content-Type": "application/activity+json"},
 | 
					            headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        logger.debug("Remote answered with %s", response.status_code)
 | 
					        logger.debug("Remote answered with %s", response.status_code)
 | 
				
			||||||
| 
						 | 
					@ -163,9 +161,7 @@ def deliver_to_remote(delivery):
 | 
				
			||||||
def fetch_nodeinfo(domain_name):
 | 
					def fetch_nodeinfo(domain_name):
 | 
				
			||||||
    s = session.get_session()
 | 
					    s = session.get_session()
 | 
				
			||||||
    wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name)
 | 
					    wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name)
 | 
				
			||||||
    response = s.get(
 | 
					    response = s.get(url=wellknown_url)
 | 
				
			||||||
        url=wellknown_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    response.raise_for_status()
 | 
					    response.raise_for_status()
 | 
				
			||||||
    serializer = serializers.NodeInfoSerializer(data=response.json())
 | 
					    serializer = serializers.NodeInfoSerializer(data=response.json())
 | 
				
			||||||
    serializer.is_valid(raise_exception=True)
 | 
					    serializer.is_valid(raise_exception=True)
 | 
				
			||||||
| 
						 | 
					@ -175,9 +171,7 @@ def fetch_nodeinfo(domain_name):
 | 
				
			||||||
            nodeinfo_url = link["href"]
 | 
					            nodeinfo_url = link["href"]
 | 
				
			||||||
            break
 | 
					            break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    response = s.get(
 | 
					    response = s.get(url=nodeinfo_url)
 | 
				
			||||||
        url=nodeinfo_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    response.raise_for_status()
 | 
					    response.raise_for_status()
 | 
				
			||||||
    return response.json()
 | 
					    return response.json()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -308,8 +302,6 @@ def fetch(fetch):
 | 
				
			||||||
        response = session.get_session().get(
 | 
					        response = session.get_session().get(
 | 
				
			||||||
            auth=auth,
 | 
					            auth=auth,
 | 
				
			||||||
            url=fetch.url,
 | 
					            url=fetch.url,
 | 
				
			||||||
            timeout=5,
 | 
					 | 
				
			||||||
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
            headers={"Content-Type": "application/activity+json"},
 | 
					            headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        logger.debug("Remote answered with %s", response.status_code)
 | 
					        logger.debug("Remote answered with %s", response.status_code)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -84,8 +84,6 @@ def retrieve_ap_object(
 | 
				
			||||||
    response = session.get_session().get(
 | 
					    response = session.get_session().get(
 | 
				
			||||||
        fid,
 | 
					        fid,
 | 
				
			||||||
        auth=auth,
 | 
					        auth=auth,
 | 
				
			||||||
        timeout=5,
 | 
					 | 
				
			||||||
        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
        headers={
 | 
					        headers={
 | 
				
			||||||
            "Accept": "application/activity+json",
 | 
					            "Accept": "application/activity+json",
 | 
				
			||||||
            "Content-Type": "application/activity+json",
 | 
					            "Content-Type": "application/activity+json",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.core import paginator
 | 
					from django.core import paginator
 | 
				
			||||||
 | 
					from django.db.models import Prefetch
 | 
				
			||||||
from django.http import HttpResponse
 | 
					from django.http import HttpResponse
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from rest_framework import exceptions, mixins, permissions, response, viewsets
 | 
					from rest_framework import exceptions, mixins, permissions, response, viewsets
 | 
				
			||||||
| 
						 | 
					@ -163,7 +164,7 @@ class MusicLibraryViewSet(
 | 
				
			||||||
    FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
 | 
					    FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
    authentication_classes = [authentication.SignatureAuthentication]
 | 
					    authentication_classes = [authentication.SignatureAuthentication]
 | 
				
			||||||
    renderer_classes = renderers.get_ap_renderers()
 | 
					    # renderer_classes = renderers.get_ap_renderers()
 | 
				
			||||||
    serializer_class = serializers.LibrarySerializer
 | 
					    serializer_class = serializers.LibrarySerializer
 | 
				
			||||||
    queryset = music_models.Library.objects.all().select_related("actor")
 | 
					    queryset = music_models.Library.objects.all().select_related("actor")
 | 
				
			||||||
    lookup_field = "uuid"
 | 
					    lookup_field = "uuid"
 | 
				
			||||||
| 
						 | 
					@ -176,7 +177,25 @@ class MusicLibraryViewSet(
 | 
				
			||||||
            "actor": lb.actor,
 | 
					            "actor": lb.actor,
 | 
				
			||||||
            "name": lb.name,
 | 
					            "name": lb.name,
 | 
				
			||||||
            "summary": lb.description,
 | 
					            "summary": lb.description,
 | 
				
			||||||
            "items": lb.uploads.for_federation().order_by("-creation_date"),
 | 
					            "items": lb.uploads.for_federation()
 | 
				
			||||||
 | 
					            .order_by("-creation_date")
 | 
				
			||||||
 | 
					            .prefetch_related(
 | 
				
			||||||
 | 
					                Prefetch(
 | 
				
			||||||
 | 
					                    "track",
 | 
				
			||||||
 | 
					                    queryset=music_models.Track.objects.select_related(
 | 
				
			||||||
 | 
					                        "album__artist__attributed_to",
 | 
				
			||||||
 | 
					                        "artist__attributed_to",
 | 
				
			||||||
 | 
					                        "album__attributed_to",
 | 
				
			||||||
 | 
					                        "attributed_to",
 | 
				
			||||||
 | 
					                        "album__attachment_cover",
 | 
				
			||||||
 | 
					                    ).prefetch_related(
 | 
				
			||||||
 | 
					                        "tagged_items__tag",
 | 
				
			||||||
 | 
					                        "album__tagged_items__tag",
 | 
				
			||||||
 | 
					                        "album__artist__tagged_items__tag",
 | 
				
			||||||
 | 
					                        "artist__tagged_items__tag",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
            "item_serializer": serializers.UploadSerializer,
 | 
					            "item_serializer": serializers.UploadSerializer,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        page = request.GET.get("page")
 | 
					        page = request.GET.get("page")
 | 
				
			||||||
| 
						 | 
					@ -219,7 +238,10 @@ class MusicUploadViewSet(
 | 
				
			||||||
    authentication_classes = [authentication.SignatureAuthentication]
 | 
					    authentication_classes = [authentication.SignatureAuthentication]
 | 
				
			||||||
    renderer_classes = renderers.get_ap_renderers()
 | 
					    renderer_classes = renderers.get_ap_renderers()
 | 
				
			||||||
    queryset = music_models.Upload.objects.local().select_related(
 | 
					    queryset = music_models.Upload.objects.local().select_related(
 | 
				
			||||||
        "library__actor", "track__artist", "track__album__artist"
 | 
					        "library__actor",
 | 
				
			||||||
 | 
					        "track__artist",
 | 
				
			||||||
 | 
					        "track__album__artist",
 | 
				
			||||||
 | 
					        "track__album__attachment_cover",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    serializer_class = serializers.UploadSerializer
 | 
					    serializer_class = serializers.UploadSerializer
 | 
				
			||||||
    lookup_field = "uuid"
 | 
					    lookup_field = "uuid"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,9 +41,7 @@ def get_resource(resource_string):
 | 
				
			||||||
    url = "https://{}/.well-known/webfinger?resource={}".format(
 | 
					    url = "https://{}/.well-known/webfinger?resource={}".format(
 | 
				
			||||||
        hostname, resource_string
 | 
					        hostname, resource_string
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    response = session.get_session().get(
 | 
					    response = session.get_session().get(url)
 | 
				
			||||||
        url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    response.raise_for_status()
 | 
					    response.raise_for_status()
 | 
				
			||||||
    serializer = serializers.ActorWebfingerSerializer(data=response.json())
 | 
					    serializer = serializers.ActorWebfingerSerializer(data=response.json())
 | 
				
			||||||
    serializer.is_valid(raise_exception=True)
 | 
					    serializer.is_valid(raise_exception=True)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -69,9 +69,9 @@ class ManageArtistViewSet(
 | 
				
			||||||
            "tracks",
 | 
					            "tracks",
 | 
				
			||||||
            Prefetch(
 | 
					            Prefetch(
 | 
				
			||||||
                "albums",
 | 
					                "albums",
 | 
				
			||||||
                queryset=music_models.Album.objects.annotate(
 | 
					                queryset=music_models.Album.objects.select_related(
 | 
				
			||||||
                    tracks_count=Count("tracks")
 | 
					                    "attachment_cover"
 | 
				
			||||||
                ),
 | 
					                ).annotate(tracks_count=Count("tracks")),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            music_views.TAG_PREFETCH,
 | 
					            music_views.TAG_PREFETCH,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
| 
						 | 
					@ -110,7 +110,7 @@ class ManageAlbumViewSet(
 | 
				
			||||||
    queryset = (
 | 
					    queryset = (
 | 
				
			||||||
        music_models.Album.objects.all()
 | 
					        music_models.Album.objects.all()
 | 
				
			||||||
        .order_by("-id")
 | 
					        .order_by("-id")
 | 
				
			||||||
        .select_related("attributed_to", "artist")
 | 
					        .select_related("attributed_to", "artist", "attachment_cover")
 | 
				
			||||||
        .prefetch_related("tracks", music_views.TAG_PREFETCH)
 | 
					        .prefetch_related("tracks", music_views.TAG_PREFETCH)
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    serializer_class = serializers.ManageAlbumSerializer
 | 
					    serializer_class = serializers.ManageAlbumSerializer
 | 
				
			||||||
| 
						 | 
					@ -153,7 +153,9 @@ class ManageTrackViewSet(
 | 
				
			||||||
    queryset = (
 | 
					    queryset = (
 | 
				
			||||||
        music_models.Track.objects.all()
 | 
					        music_models.Track.objects.all()
 | 
				
			||||||
        .order_by("-id")
 | 
					        .order_by("-id")
 | 
				
			||||||
        .select_related("attributed_to", "artist", "album__artist")
 | 
					        .select_related(
 | 
				
			||||||
 | 
					            "attributed_to", "artist", "album__artist", "album__attachment_cover"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
 | 
					        .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
 | 
				
			||||||
        .prefetch_related(music_views.TAG_PREFETCH)
 | 
					        .prefetch_related(music_views.TAG_PREFETCH)
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,8 +6,6 @@ import logging
 | 
				
			||||||
from django.core.management.base import BaseCommand, CommandError
 | 
					from django.core.management.base import BaseCommand, CommandError
 | 
				
			||||||
from django.core import validators
 | 
					from django.core import validators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from funkwhale_api.common import session
 | 
					from funkwhale_api.common import session
 | 
				
			||||||
from funkwhale_api.federation import models
 | 
					from funkwhale_api.federation import models
 | 
				
			||||||
from funkwhale_api.moderation import mrf
 | 
					from funkwhale_api.moderation import mrf
 | 
				
			||||||
| 
						 | 
					@ -84,10 +82,7 @@ class Command(BaseCommand):
 | 
				
			||||||
            content = models.Activity.objects.get(uuid=input).payload
 | 
					            content = models.Activity.objects.get(uuid=input).payload
 | 
				
			||||||
        elif is_url(input):
 | 
					        elif is_url(input):
 | 
				
			||||||
            response = session.get_session().get(
 | 
					            response = session.get_session().get(
 | 
				
			||||||
                input,
 | 
					                input, headers={"Content-Type": "application/activity+json"},
 | 
				
			||||||
                timeout=5,
 | 
					 | 
				
			||||||
                verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
                headers={"Content-Type": "application/activity+json"},
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            response.raise_for_status()
 | 
					            response.raise_for_status()
 | 
				
			||||||
            content = response.json()
 | 
					            content = response.json()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ import factory
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.factories import registry, NoUpdateOnCreate
 | 
					from funkwhale_api.factories import registry, NoUpdateOnCreate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from funkwhale_api.common import factories as common_factories
 | 
				
			||||||
from funkwhale_api.federation import factories as federation_factories
 | 
					from funkwhale_api.federation import factories as federation_factories
 | 
				
			||||||
from funkwhale_api.music import licenses
 | 
					from funkwhale_api.music import licenses
 | 
				
			||||||
from funkwhale_api.tags import factories as tags_factories
 | 
					from funkwhale_api.tags import factories as tags_factories
 | 
				
			||||||
| 
						 | 
					@ -81,7 +82,7 @@ class AlbumFactory(
 | 
				
			||||||
    title = factory.Faker("sentence", nb_words=3)
 | 
					    title = factory.Faker("sentence", nb_words=3)
 | 
				
			||||||
    mbid = factory.Faker("uuid4")
 | 
					    mbid = factory.Faker("uuid4")
 | 
				
			||||||
    release_date = factory.Faker("date_object")
 | 
					    release_date = factory.Faker("date_object")
 | 
				
			||||||
    cover = factory.django.ImageField()
 | 
					    attachment_cover = factory.SubFactory(common_factories.AttachmentFactory)
 | 
				
			||||||
    artist = factory.SubFactory(ArtistFactory)
 | 
					    artist = factory.SubFactory(ArtistFactory)
 | 
				
			||||||
    release_group_id = factory.Faker("uuid4")
 | 
					    release_group_id = factory.Faker("uuid4")
 | 
				
			||||||
    fid = factory.Faker("federation_url")
 | 
					    fid = factory.Faker("federation_url")
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					# Generated by Django 2.2.6 on 2019-11-12 09:56
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('common', '0004_auto_20191111_1338'),
 | 
				
			||||||
 | 
					        ('music', '0041_auto_20191021_1705'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='album',
 | 
				
			||||||
 | 
					            name='attachment_cover',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Attachment', blank=True),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,41 @@
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					from __future__ import unicode_literals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_attachments(apps, schema_editor):
 | 
				
			||||||
 | 
					    Album = apps.get_model("music", "Album")
 | 
				
			||||||
 | 
					    Attachment = apps.get_model("common", "Attachment")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    album_attachment_mapping = {}
 | 
				
			||||||
 | 
					    def get_mimetype(path):
 | 
				
			||||||
 | 
					        if path.lower().endswith('.png'):
 | 
				
			||||||
 | 
					            return "image/png"
 | 
				
			||||||
 | 
					        return "image/jpeg"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for album in Album.objects.filter(attachment_cover=None).exclude(cover="").exclude(cover=None):
 | 
				
			||||||
 | 
					        album_attachment_mapping[album] = Attachment(
 | 
				
			||||||
 | 
					            file=album.cover,
 | 
				
			||||||
 | 
					            size=album.cover.size,
 | 
				
			||||||
 | 
					            mimetype=get_mimetype(album.cover.path),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Attachment.objects.bulk_create(album_attachment_mapping.values(), batch_size=2000)
 | 
				
			||||||
 | 
					    # map each attachment to the corresponding album
 | 
				
			||||||
 | 
					    # and bulk save
 | 
				
			||||||
 | 
					    for album, attachment in album_attachment_mapping.items():
 | 
				
			||||||
 | 
					        album.attachment_cover = attachment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Album.objects.bulk_update(album_attachment_mapping.keys(), fields=['attachment_cover'], batch_size=2000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def rewind(apps, schema_editor):
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [("music", "0042_album_attachment_cover")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [migrations.RunPython(create_attachments, rewind)]
 | 
				
			||||||
| 
						 | 
					@ -20,7 +20,6 @@ from django.urls import reverse
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from versatileimagefield.fields import VersatileImageField
 | 
					from versatileimagefield.fields import VersatileImageField
 | 
				
			||||||
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api import musicbrainz
 | 
					from funkwhale_api import musicbrainz
 | 
				
			||||||
from funkwhale_api.common import fields
 | 
					from funkwhale_api.common import fields
 | 
				
			||||||
| 
						 | 
					@ -286,9 +285,17 @@ class Album(APIModelMixin):
 | 
				
			||||||
    artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
 | 
					    artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
 | 
				
			||||||
    release_date = models.DateField(null=True, blank=True, db_index=True)
 | 
					    release_date = models.DateField(null=True, blank=True, db_index=True)
 | 
				
			||||||
    release_group_id = models.UUIDField(null=True, blank=True)
 | 
					    release_group_id = models.UUIDField(null=True, blank=True)
 | 
				
			||||||
 | 
					    # XXX: 1.0 clean this uneeded field in favor of attachment_cover
 | 
				
			||||||
    cover = VersatileImageField(
 | 
					    cover = VersatileImageField(
 | 
				
			||||||
        upload_to="albums/covers/%Y/%m/%d", null=True, blank=True
 | 
					        upload_to="albums/covers/%Y/%m/%d", null=True, blank=True
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    attachment_cover = models.ForeignKey(
 | 
				
			||||||
 | 
					        "common.Attachment",
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
 | 
					        on_delete=models.SET_NULL,
 | 
				
			||||||
 | 
					        related_name="covered_album",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    TYPE_CHOICES = (("album", "Album"),)
 | 
					    TYPE_CHOICES = (("album", "Album"),)
 | 
				
			||||||
    type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
 | 
					    type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -334,40 +341,46 @@ class Album(APIModelMixin):
 | 
				
			||||||
    objects = AlbumQuerySet.as_manager()
 | 
					    objects = AlbumQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_image(self, data=None):
 | 
					    def get_image(self, data=None):
 | 
				
			||||||
 | 
					        from funkwhale_api.common import tasks as common_tasks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        attachment = None
 | 
				
			||||||
        if data:
 | 
					        if data:
 | 
				
			||||||
            extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
 | 
					            extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
 | 
				
			||||||
            extension = extensions.get(data["mimetype"], "jpg")
 | 
					            extension = extensions.get(data["mimetype"], "jpg")
 | 
				
			||||||
 | 
					            attachment = common_models.Attachment(mimetype=data["mimetype"])
 | 
				
			||||||
            f = None
 | 
					            f = None
 | 
				
			||||||
 | 
					            filename = "{}.{}".format(self.uuid, extension)
 | 
				
			||||||
            if data.get("content"):
 | 
					            if data.get("content"):
 | 
				
			||||||
                # we have to cover itself
 | 
					                # we have to cover itself
 | 
				
			||||||
                f = ContentFile(data["content"])
 | 
					                f = ContentFile(data["content"])
 | 
				
			||||||
 | 
					                attachment.file.save(filename, f, save=False)
 | 
				
			||||||
            elif data.get("url"):
 | 
					            elif data.get("url"):
 | 
				
			||||||
 | 
					                attachment.url = data.get("url")
 | 
				
			||||||
                # we can fetch from a url
 | 
					                # we can fetch from a url
 | 
				
			||||||
                try:
 | 
					                try:
 | 
				
			||||||
                    response = session.get_session().get(
 | 
					                    common_tasks.fetch_remote_attachment(
 | 
				
			||||||
                        data.get("url"),
 | 
					                        attachment, filename=filename, save=False
 | 
				
			||||||
                        timeout=3,
 | 
					 | 
				
			||||||
                        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    response.raise_for_status()
 | 
					 | 
				
			||||||
                except Exception as e:
 | 
					                except Exception as e:
 | 
				
			||||||
                    logger.warn(
 | 
					                    logger.warn(
 | 
				
			||||||
                        "Cannot download cover at url %s: %s", data.get("url"), e
 | 
					                        "Cannot download cover at url %s: %s", data.get("url"), e
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
                else:
 | 
					
 | 
				
			||||||
                    f = ContentFile(response.content)
 | 
					        elif self.mbid:
 | 
				
			||||||
            if f:
 | 
					 | 
				
			||||||
                self.cover.save("{}.{}".format(self.uuid, extension), f, save=False)
 | 
					 | 
				
			||||||
                self.save(update_fields=["cover"])
 | 
					 | 
				
			||||||
                return self.cover.file
 | 
					 | 
				
			||||||
        if self.mbid:
 | 
					 | 
				
			||||||
            image_data = musicbrainz.api.images.get_front(str(self.mbid))
 | 
					            image_data = musicbrainz.api.images.get_front(str(self.mbid))
 | 
				
			||||||
            f = ContentFile(image_data)
 | 
					            f = ContentFile(image_data)
 | 
				
			||||||
            self.cover.save("{0}.jpg".format(self.mbid), f, save=False)
 | 
					            attachment = common_models.Attachment(mimetype="image/jpeg")
 | 
				
			||||||
            self.save(update_fields=["cover"])
 | 
					            attachment.file.save("{0}.jpg".format(self.mbid), f, save=False)
 | 
				
			||||||
        if self.cover:
 | 
					        if attachment and attachment.file:
 | 
				
			||||||
            return self.cover.file
 | 
					            attachment.save()
 | 
				
			||||||
 | 
					            self.attachment_cover = attachment
 | 
				
			||||||
 | 
					            self.save(update_fields=["attachment_cover"])
 | 
				
			||||||
 | 
					            return self.attachment_cover.file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def cover(self):
 | 
				
			||||||
 | 
					        return self.attachment_cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return self.title
 | 
					        return self.title
 | 
				
			||||||
| 
						 | 
					@ -378,16 +391,6 @@ class Album(APIModelMixin):
 | 
				
			||||||
    def get_moderation_url(self):
 | 
					    def get_moderation_url(self):
 | 
				
			||||||
        return "/manage/library/albums/{}".format(self.pk)
 | 
					        return "/manage/library/albums/{}".format(self.pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def cover_path(self):
 | 
					 | 
				
			||||||
        if not self.cover:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return self.cover.path
 | 
					 | 
				
			||||||
        except NotImplementedError:
 | 
					 | 
				
			||||||
            # external storage
 | 
					 | 
				
			||||||
            return self.cover.name
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def get_or_create_from_title(cls, title, **kwargs):
 | 
					    def get_or_create_from_title(cls, title, **kwargs):
 | 
				
			||||||
        kwargs.update({"title": title})
 | 
					        kwargs.update({"title": title})
 | 
				
			||||||
| 
						 | 
					@ -415,7 +418,9 @@ def import_album(v):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
 | 
					class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
 | 
				
			||||||
    def for_nested_serialization(self):
 | 
					    def for_nested_serialization(self):
 | 
				
			||||||
        return self.prefetch_related("artist", "album__artist")
 | 
					        return self.prefetch_related(
 | 
				
			||||||
 | 
					            "artist", "album__artist", "album__attachment_cover"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def annotate_playable_by_actor(self, actor):
 | 
					    def annotate_playable_by_actor(self, actor):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -729,7 +734,6 @@ class Upload(models.Model):
 | 
				
			||||||
        return parsed.hostname
 | 
					        return parsed.hostname
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def download_audio_from_remote(self, actor):
 | 
					    def download_audio_from_remote(self, actor):
 | 
				
			||||||
        from funkwhale_api.common import session
 | 
					 | 
				
			||||||
        from funkwhale_api.federation import signing
 | 
					        from funkwhale_api.federation import signing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if actor:
 | 
					        if actor:
 | 
				
			||||||
| 
						 | 
					@ -743,7 +747,6 @@ class Upload(models.Model):
 | 
				
			||||||
            stream=True,
 | 
					            stream=True,
 | 
				
			||||||
            timeout=20,
 | 
					            timeout=20,
 | 
				
			||||||
            headers={"Content-Type": "application/octet-stream"},
 | 
					            headers={"Content-Type": "application/octet-stream"},
 | 
				
			||||||
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        with remote_response as r:
 | 
					        with remote_response as r:
 | 
				
			||||||
            remote_response.raise_for_status()
 | 
					            remote_response.raise_for_status()
 | 
				
			||||||
| 
						 | 
					@ -1307,13 +1310,3 @@ def update_request_status(sender, instance, created, **kwargs):
 | 
				
			||||||
        # let's mark the request as imported since the import is over
 | 
					        # let's mark the request as imported since the import is over
 | 
				
			||||||
        instance.import_request.status = "imported"
 | 
					        instance.import_request.status = "imported"
 | 
				
			||||||
        return instance.import_request.save(update_fields=["status"])
 | 
					        return instance.import_request.save(update_fields=["status"])
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@receiver(models.signals.post_save, sender=Album)
 | 
					 | 
				
			||||||
def warm_album_covers(sender, instance, **kwargs):
 | 
					 | 
				
			||||||
    if not instance.cover or not settings.CREATE_IMAGE_THUMBNAILS:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    album_covers_warmer = VersatileImageFieldWarmer(
 | 
					 | 
				
			||||||
        instance_or_queryset=instance, rendition_key_set="square", image_attr="cover"
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    num_created, failed_to_create = album_covers_warmer.warm()
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,7 +4,6 @@ from django.db import transaction
 | 
				
			||||||
from django import urls
 | 
					from django import urls
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from rest_framework import serializers
 | 
					from rest_framework import serializers
 | 
				
			||||||
from versatileimagefield.serializers import VersatileImageFieldSerializer
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.activity import serializers as activity_serializers
 | 
					from funkwhale_api.activity import serializers as activity_serializers
 | 
				
			||||||
from funkwhale_api.common import serializers as common_serializers
 | 
					from funkwhale_api.common import serializers as common_serializers
 | 
				
			||||||
| 
						 | 
					@ -17,7 +16,25 @@ from funkwhale_api.tags.models import Tag
 | 
				
			||||||
from . import filters, models, tasks
 | 
					from . import filters, models, tasks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
 | 
					class NullToEmptDict(object):
 | 
				
			||||||
 | 
					    def get_attribute(self, o):
 | 
				
			||||||
 | 
					        attr = super().get_attribute(o)
 | 
				
			||||||
 | 
					        if attr is None:
 | 
				
			||||||
 | 
					            return {}
 | 
				
			||||||
 | 
					        return attr
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_representation(self, v):
 | 
				
			||||||
 | 
					        if not v:
 | 
				
			||||||
 | 
					            return v
 | 
				
			||||||
 | 
					        return super().to_representation(v)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CoverField(NullToEmptDict, common_serializers.AttachmentSerializer):
 | 
				
			||||||
 | 
					    # XXX: BACKWARD COMPATIBILITY
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					cover_field = CoverField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def serialize_attributed_to(self, obj):
 | 
					def serialize_attributed_to(self, obj):
 | 
				
			||||||
| 
						 | 
					@ -450,12 +467,12 @@ class OembedSerializer(serializers.Serializer):
 | 
				
			||||||
            embed_type = "track"
 | 
					            embed_type = "track"
 | 
				
			||||||
            embed_id = track.pk
 | 
					            embed_id = track.pk
 | 
				
			||||||
            data["title"] = "{} by {}".format(track.title, track.artist.name)
 | 
					            data["title"] = "{} by {}".format(track.title, track.artist.name)
 | 
				
			||||||
            if track.album.cover:
 | 
					            if track.album.attachment_cover:
 | 
				
			||||||
                data["thumbnail_url"] = federation_utils.full_url(
 | 
					                data[
 | 
				
			||||||
                    track.album.cover.crop["400x400"].url
 | 
					                    "thumbnail_url"
 | 
				
			||||||
                )
 | 
					                ] = track.album.attachment_cover.download_url_medium_square_crop
 | 
				
			||||||
                data["thumbnail_width"] = 400
 | 
					                data["thumbnail_width"] = 200
 | 
				
			||||||
                data["thumbnail_height"] = 400
 | 
					                data["thumbnail_height"] = 200
 | 
				
			||||||
            data["description"] = track.full_name
 | 
					            data["description"] = track.full_name
 | 
				
			||||||
            data["author_name"] = track.artist.name
 | 
					            data["author_name"] = track.artist.name
 | 
				
			||||||
            data["height"] = 150
 | 
					            data["height"] = 150
 | 
				
			||||||
| 
						 | 
					@ -476,12 +493,12 @@ class OembedSerializer(serializers.Serializer):
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            embed_type = "album"
 | 
					            embed_type = "album"
 | 
				
			||||||
            embed_id = album.pk
 | 
					            embed_id = album.pk
 | 
				
			||||||
            if album.cover:
 | 
					            if album.attachment_cover:
 | 
				
			||||||
                data["thumbnail_url"] = federation_utils.full_url(
 | 
					                data[
 | 
				
			||||||
                    album.cover.crop["400x400"].url
 | 
					                    "thumbnail_url"
 | 
				
			||||||
                )
 | 
					                ] = album.attachment_cover.download_url_medium_square_crop
 | 
				
			||||||
                data["thumbnail_width"] = 400
 | 
					                data["thumbnail_width"] = 200
 | 
				
			||||||
                data["thumbnail_height"] = 400
 | 
					                data["thumbnail_height"] = 200
 | 
				
			||||||
            data["title"] = "{} by {}".format(album.title, album.artist.name)
 | 
					            data["title"] = "{} by {}".format(album.title, album.artist.name)
 | 
				
			||||||
            data["description"] = "{} by {}".format(album.title, album.artist.name)
 | 
					            data["description"] = "{} by {}".format(album.title, album.artist.name)
 | 
				
			||||||
            data["author_name"] = album.artist.name
 | 
					            data["author_name"] = album.artist.name
 | 
				
			||||||
| 
						 | 
					@ -501,19 +518,14 @@ class OembedSerializer(serializers.Serializer):
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            embed_type = "artist"
 | 
					            embed_type = "artist"
 | 
				
			||||||
            embed_id = artist.pk
 | 
					            embed_id = artist.pk
 | 
				
			||||||
            album = (
 | 
					            album = artist.albums.exclude(attachment_cover=None).order_by("-id").first()
 | 
				
			||||||
                artist.albums.filter(cover__isnull=False)
 | 
					 | 
				
			||||||
                .exclude(cover="")
 | 
					 | 
				
			||||||
                .order_by("-id")
 | 
					 | 
				
			||||||
                .first()
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if album and album.cover:
 | 
					            if album and album.attachment_cover:
 | 
				
			||||||
                data["thumbnail_url"] = federation_utils.full_url(
 | 
					                data[
 | 
				
			||||||
                    album.cover.crop["400x400"].url
 | 
					                    "thumbnail_url"
 | 
				
			||||||
                )
 | 
					                ] = album.attachment_cover.download_url_medium_square_crop
 | 
				
			||||||
                data["thumbnail_width"] = 400
 | 
					                data["thumbnail_width"] = 200
 | 
				
			||||||
                data["thumbnail_height"] = 400
 | 
					                data["thumbnail_height"] = 200
 | 
				
			||||||
            data["title"] = artist.name
 | 
					            data["title"] = artist.name
 | 
				
			||||||
            data["description"] = artist.name
 | 
					            data["description"] = artist.name
 | 
				
			||||||
            data["author_name"] = artist.name
 | 
					            data["author_name"] = artist.name
 | 
				
			||||||
| 
						 | 
					@ -533,19 +545,22 @@ class OembedSerializer(serializers.Serializer):
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            embed_type = "playlist"
 | 
					            embed_type = "playlist"
 | 
				
			||||||
            embed_id = obj.pk
 | 
					            embed_id = obj.pk
 | 
				
			||||||
            playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="")
 | 
					            playlist_tracks = obj.playlist_tracks.exclude(
 | 
				
			||||||
            playlist_tracks = playlist_tracks.exclude(track__album__cover=None)
 | 
					                track__album__attachment_cover=None
 | 
				
			||||||
            playlist_tracks = playlist_tracks.select_related("track__album").order_by(
 | 
					 | 
				
			||||||
                "index"
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            playlist_tracks = playlist_tracks.select_related(
 | 
				
			||||||
 | 
					                "track__album__attachment_cover"
 | 
				
			||||||
 | 
					            ).order_by("index")
 | 
				
			||||||
            first_playlist_track = playlist_tracks.first()
 | 
					            first_playlist_track = playlist_tracks.first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if first_playlist_track:
 | 
					            if first_playlist_track:
 | 
				
			||||||
                data["thumbnail_url"] = federation_utils.full_url(
 | 
					                data[
 | 
				
			||||||
                    first_playlist_track.track.album.cover.crop["400x400"].url
 | 
					                    "thumbnail_url"
 | 
				
			||||||
 | 
					                ] = (
 | 
				
			||||||
 | 
					                    first_playlist_track.track.album.attachment_cover.download_url_medium_square_crop
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                data["thumbnail_width"] = 400
 | 
					                data["thumbnail_width"] = 200
 | 
				
			||||||
                data["thumbnail_height"] = 400
 | 
					                data["thumbnail_height"] = 200
 | 
				
			||||||
            data["title"] = obj.name
 | 
					            data["title"] = obj.name
 | 
				
			||||||
            data["description"] = obj.name
 | 
					            data["description"] = obj.name
 | 
				
			||||||
            data["author_name"] = obj.name
 | 
					            data["author_name"] = obj.name
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -57,14 +57,12 @@ def library_track(request, pk):
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    if obj.album.cover:
 | 
					    if obj.album.attachment_cover:
 | 
				
			||||||
        metas.append(
 | 
					        metas.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "tag": "meta",
 | 
					                "tag": "meta",
 | 
				
			||||||
                "property": "og:image",
 | 
					                "property": "og:image",
 | 
				
			||||||
                "content": utils.join_url(
 | 
					                "content": obj.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                    settings.FUNKWHALE_URL, obj.album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -126,14 +124,12 @@ def library_album(request, pk):
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if obj.cover:
 | 
					    if obj.attachment_cover:
 | 
				
			||||||
        metas.append(
 | 
					        metas.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "tag": "meta",
 | 
					                "tag": "meta",
 | 
				
			||||||
                "property": "og:image",
 | 
					                "property": "og:image",
 | 
				
			||||||
                "content": utils.join_url(
 | 
					                "content": obj.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                    settings.FUNKWHALE_URL, obj.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -166,7 +162,7 @@ def library_artist(request, pk):
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    # we use latest album's cover as artist image
 | 
					    # we use latest album's cover as artist image
 | 
				
			||||||
    latest_album = (
 | 
					    latest_album = (
 | 
				
			||||||
        obj.albums.exclude(cover="").exclude(cover=None).order_by("release_date").last()
 | 
					        obj.albums.exclude(attachment_cover=None).order_by("release_date").last()
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    metas = [
 | 
					    metas = [
 | 
				
			||||||
        {"tag": "meta", "property": "og:url", "content": artist_url},
 | 
					        {"tag": "meta", "property": "og:url", "content": artist_url},
 | 
				
			||||||
| 
						 | 
					@ -174,14 +170,12 @@ def library_artist(request, pk):
 | 
				
			||||||
        {"tag": "meta", "property": "og:type", "content": "profile"},
 | 
					        {"tag": "meta", "property": "og:type", "content": "profile"},
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if latest_album and latest_album.cover:
 | 
					    if latest_album and latest_album.attachment_cover:
 | 
				
			||||||
        metas.append(
 | 
					        metas.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "tag": "meta",
 | 
					                "tag": "meta",
 | 
				
			||||||
                "property": "og:image",
 | 
					                "property": "og:image",
 | 
				
			||||||
                "content": utils.join_url(
 | 
					                "content": latest_album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                    settings.FUNKWHALE_URL, latest_album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -217,8 +211,7 @@ def library_playlist(request, pk):
 | 
				
			||||||
        utils.spa_reverse("library_playlist", kwargs={"pk": obj.pk}),
 | 
					        utils.spa_reverse("library_playlist", kwargs={"pk": obj.pk}),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    # we use the first playlist track's album's cover as image
 | 
					    # we use the first playlist track's album's cover as image
 | 
				
			||||||
    playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="")
 | 
					    playlist_tracks = obj.playlist_tracks.exclude(track__album__attachment_cover=None)
 | 
				
			||||||
    playlist_tracks = playlist_tracks.exclude(track__album__cover=None)
 | 
					 | 
				
			||||||
    playlist_tracks = playlist_tracks.select_related("track__album").order_by("index")
 | 
					    playlist_tracks = playlist_tracks.select_related("track__album").order_by("index")
 | 
				
			||||||
    first_playlist_track = playlist_tracks.first()
 | 
					    first_playlist_track = playlist_tracks.first()
 | 
				
			||||||
    metas = [
 | 
					    metas = [
 | 
				
			||||||
| 
						 | 
					@ -232,10 +225,7 @@ def library_playlist(request, pk):
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "tag": "meta",
 | 
					                "tag": "meta",
 | 
				
			||||||
                "property": "og:image",
 | 
					                "property": "og:image",
 | 
				
			||||||
                "content": utils.join_url(
 | 
					                "content": first_playlist_track.track.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                    settings.FUNKWHALE_URL,
 | 
					 | 
				
			||||||
                    first_playlist_track.track.album.cover.crop["400x400"].url,
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,7 +21,6 @@ from . import licenses
 | 
				
			||||||
from . import models
 | 
					from . import models
 | 
				
			||||||
from . import metadata
 | 
					from . import metadata
 | 
				
			||||||
from . import signals
 | 
					from . import signals
 | 
				
			||||||
from . import serializers
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -29,7 +28,7 @@ logger = logging.getLogger(__name__)
 | 
				
			||||||
def update_album_cover(
 | 
					def update_album_cover(
 | 
				
			||||||
    album, source=None, cover_data=None, musicbrainz=True, replace=False
 | 
					    album, source=None, cover_data=None, musicbrainz=True, replace=False
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
    if album.cover and not replace:
 | 
					    if album.attachment_cover and not replace:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    if cover_data:
 | 
					    if cover_data:
 | 
				
			||||||
        return album.get_image(data=cover_data)
 | 
					        return album.get_image(data=cover_data)
 | 
				
			||||||
| 
						 | 
					@ -257,7 +256,7 @@ def process_upload(upload, update_denormalization=True):
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # update album cover, if needed
 | 
					    # update album cover, if needed
 | 
				
			||||||
    if not track.album.cover:
 | 
					    if not track.album.attachment_cover:
 | 
				
			||||||
        update_album_cover(
 | 
					        update_album_cover(
 | 
				
			||||||
            track.album,
 | 
					            track.album,
 | 
				
			||||||
            source=final_metadata.get("upload_source"),
 | 
					            source=final_metadata.get("upload_source"),
 | 
				
			||||||
| 
						 | 
					@ -404,7 +403,7 @@ def sort_candidates(candidates, important_fields):
 | 
				
			||||||
@transaction.atomic
 | 
					@transaction.atomic
 | 
				
			||||||
def get_track_from_import_metadata(data, update_cover=False, attributed_to=None):
 | 
					def get_track_from_import_metadata(data, update_cover=False, attributed_to=None):
 | 
				
			||||||
    track = _get_track(data, attributed_to=attributed_to)
 | 
					    track = _get_track(data, attributed_to=attributed_to)
 | 
				
			||||||
    if update_cover and track and not track.album.cover:
 | 
					    if update_cover and track and not track.album.attachment_cover:
 | 
				
			||||||
        update_album_cover(
 | 
					        update_album_cover(
 | 
				
			||||||
            track.album,
 | 
					            track.album,
 | 
				
			||||||
            source=data.get("upload_source"),
 | 
					            source=data.get("upload_source"),
 | 
				
			||||||
| 
						 | 
					@ -584,6 +583,8 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw
 | 
				
			||||||
    if not user:
 | 
					    if not user:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from . import serializers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    group = "user.{}.imports".format(user.pk)
 | 
					    group = "user.{}.imports".format(user.pk)
 | 
				
			||||||
    channels.group_send(
 | 
					    channels.group_send(
 | 
				
			||||||
        group,
 | 
					        group,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -128,7 +128,9 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):
 | 
				
			||||||
        queryset = super().get_queryset()
 | 
					        queryset = super().get_queryset()
 | 
				
			||||||
        albums = models.Album.objects.with_tracks_count()
 | 
					        albums = models.Album.objects.with_tracks_count().select_related(
 | 
				
			||||||
 | 
					            "attachment_cover"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        albums = albums.annotate_playable_by_actor(
 | 
					        albums = albums.annotate_playable_by_actor(
 | 
				
			||||||
            utils.get_actor_from_request(self.request)
 | 
					            utils.get_actor_from_request(self.request)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
| 
						 | 
					@ -149,7 +151,7 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
 | 
				
			||||||
    queryset = (
 | 
					    queryset = (
 | 
				
			||||||
        models.Album.objects.all()
 | 
					        models.Album.objects.all()
 | 
				
			||||||
        .order_by("-creation_date")
 | 
					        .order_by("-creation_date")
 | 
				
			||||||
        .prefetch_related("artist", "attributed_to")
 | 
					        .prefetch_related("artist", "attributed_to", "attachment_cover")
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    serializer_class = serializers.AlbumSerializer
 | 
					    serializer_class = serializers.AlbumSerializer
 | 
				
			||||||
    permission_classes = [oauth_permissions.ScopePermission]
 | 
					    permission_classes = [oauth_permissions.ScopePermission]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,7 +17,8 @@ class PlaylistQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def with_covers(self):
 | 
					    def with_covers(self):
 | 
				
			||||||
        album_prefetch = models.Prefetch(
 | 
					        album_prefetch = models.Prefetch(
 | 
				
			||||||
            "album", queryset=music_models.Album.objects.only("cover", "artist_id")
 | 
					            "album",
 | 
				
			||||||
 | 
					            queryset=music_models.Album.objects.select_related("attachment_cover"),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        track_prefetch = models.Prefetch(
 | 
					        track_prefetch = models.Prefetch(
 | 
				
			||||||
            "track",
 | 
					            "track",
 | 
				
			||||||
| 
						 | 
					@ -29,8 +30,7 @@ class PlaylistQuerySet(models.QuerySet):
 | 
				
			||||||
        plt_prefetch = models.Prefetch(
 | 
					        plt_prefetch = models.Prefetch(
 | 
				
			||||||
            "playlist_tracks",
 | 
					            "playlist_tracks",
 | 
				
			||||||
            queryset=PlaylistTrack.objects.all()
 | 
					            queryset=PlaylistTrack.objects.all()
 | 
				
			||||||
            .exclude(track__album__cover=None)
 | 
					            .exclude(track__album__attachment_cover=None)
 | 
				
			||||||
            .exclude(track__album__cover="")
 | 
					 | 
				
			||||||
            .order_by("index")
 | 
					            .order_by("index")
 | 
				
			||||||
            .only("id", "playlist_id", "track_id")
 | 
					            .only("id", "playlist_id", "track_id")
 | 
				
			||||||
            .prefetch_related(track_prefetch),
 | 
					            .prefetch_related(track_prefetch),
 | 
				
			||||||
| 
						 | 
					@ -179,7 +179,9 @@ class Playlist(models.Model):
 | 
				
			||||||
class PlaylistTrackQuerySet(models.QuerySet):
 | 
					class PlaylistTrackQuerySet(models.QuerySet):
 | 
				
			||||||
    def for_nested_serialization(self, actor=None):
 | 
					    def for_nested_serialization(self, actor=None):
 | 
				
			||||||
        tracks = music_models.Track.objects.with_playable_uploads(actor)
 | 
					        tracks = music_models.Track.objects.with_playable_uploads(actor)
 | 
				
			||||||
        tracks = tracks.select_related("artist", "album__artist")
 | 
					        tracks = tracks.select_related(
 | 
				
			||||||
 | 
					            "artist", "album__artist", "album__attachment_cover", "attributed_to"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        return self.prefetch_related(
 | 
					        return self.prefetch_related(
 | 
				
			||||||
            models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
 | 
					            models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -145,7 +145,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
 | 
				
			||||||
        for plt in plts:
 | 
					        for plt in plts:
 | 
				
			||||||
            if plt.track.album.artist_id in excluded_artists:
 | 
					            if plt.track.album.artist_id in excluded_artists:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            url = plt.track.album.cover.crop["200x200"].url
 | 
					            url = plt.track.album.attachment_cover.download_url_medium_square_crop
 | 
				
			||||||
            if url in covers:
 | 
					            if url in covers:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            covers.append(url)
 | 
					            covers.append(url)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -89,7 +89,7 @@ class GetArtistSerializer(serializers.Serializer):
 | 
				
			||||||
                "created": to_subsonic_date(album.creation_date),
 | 
					                "created": to_subsonic_date(album.creation_date),
 | 
				
			||||||
                "songCount": len(album.tracks.all()),
 | 
					                "songCount": len(album.tracks.all()),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if album.cover:
 | 
					            if album.attachment_cover_id:
 | 
				
			||||||
                album_data["coverArt"] = "al-{}".format(album.id)
 | 
					                album_data["coverArt"] = "al-{}".format(album.id)
 | 
				
			||||||
            if album.release_date:
 | 
					            if album.release_date:
 | 
				
			||||||
                album_data["year"] = album.release_date.year
 | 
					                album_data["year"] = album.release_date.year
 | 
				
			||||||
| 
						 | 
					@ -122,7 +122,7 @@ def get_track_data(album, track, upload):
 | 
				
			||||||
        "artistId": album.artist.pk,
 | 
					        "artistId": album.artist.pk,
 | 
				
			||||||
        "type": "music",
 | 
					        "type": "music",
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if track.album.cover:
 | 
					    if track.album.attachment_cover_id:
 | 
				
			||||||
        data["coverArt"] = "al-{}".format(track.album.id)
 | 
					        data["coverArt"] = "al-{}".format(track.album.id)
 | 
				
			||||||
    if upload.bitrate:
 | 
					    if upload.bitrate:
 | 
				
			||||||
        data["bitrate"] = int(upload.bitrate / 1000)
 | 
					        data["bitrate"] = int(upload.bitrate / 1000)
 | 
				
			||||||
| 
						 | 
					@ -141,7 +141,7 @@ def get_album2_data(album):
 | 
				
			||||||
        "artist": album.artist.name,
 | 
					        "artist": album.artist.name,
 | 
				
			||||||
        "created": to_subsonic_date(album.creation_date),
 | 
					        "created": to_subsonic_date(album.creation_date),
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if album.cover:
 | 
					    if album.attachment_cover_id:
 | 
				
			||||||
        payload["coverArt"] = "al-{}".format(album.id)
 | 
					        payload["coverArt"] = "al-{}".format(album.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,7 +16,12 @@ from rest_framework.serializers import ValidationError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import funkwhale_api
 | 
					import funkwhale_api
 | 
				
			||||||
from funkwhale_api.activity import record
 | 
					from funkwhale_api.activity import record
 | 
				
			||||||
from funkwhale_api.common import fields, preferences, utils as common_utils
 | 
					from funkwhale_api.common import (
 | 
				
			||||||
 | 
					    fields,
 | 
				
			||||||
 | 
					    preferences,
 | 
				
			||||||
 | 
					    utils as common_utils,
 | 
				
			||||||
 | 
					    tasks as common_tasks,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from funkwhale_api.favorites.models import TrackFavorite
 | 
					from funkwhale_api.favorites.models import TrackFavorite
 | 
				
			||||||
from funkwhale_api.moderation import filters as moderation_filters
 | 
					from funkwhale_api.moderation import filters as moderation_filters
 | 
				
			||||||
from funkwhale_api.music import models as music_models
 | 
					from funkwhale_api.music import models as music_models
 | 
				
			||||||
| 
						 | 
					@ -732,20 +737,23 @@ class SubsonicViewSet(viewsets.GenericViewSet):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                album_id = int(id.replace("al-", ""))
 | 
					                album_id = int(id.replace("al-", ""))
 | 
				
			||||||
                album = (
 | 
					                album = (
 | 
				
			||||||
                    music_models.Album.objects.exclude(cover__isnull=True)
 | 
					                    music_models.Album.objects.exclude(attachment_cover=None)
 | 
				
			||||||
                    .exclude(cover="")
 | 
					                    .select_related("attachment_cover")
 | 
				
			||||||
                    .get(pk=album_id)
 | 
					                    .get(pk=album_id)
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            except (TypeError, ValueError, music_models.Album.DoesNotExist):
 | 
					            except (TypeError, ValueError, music_models.Album.DoesNotExist):
 | 
				
			||||||
                return response.Response(
 | 
					                return response.Response(
 | 
				
			||||||
                    {"error": {"code": 70, "message": "cover art not found."}}
 | 
					                    {"error": {"code": 70, "message": "cover art not found."}}
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            cover = album.cover
 | 
					            attachment = album.attachment_cover
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            return response.Response(
 | 
					            return response.Response(
 | 
				
			||||||
                {"error": {"code": 70, "message": "cover art not found."}}
 | 
					                {"error": {"code": 70, "message": "cover art not found."}}
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not attachment.file:
 | 
				
			||||||
 | 
					            common_tasks.fetch_remote_attachment(attachment)
 | 
				
			||||||
 | 
					        cover = attachment.file
 | 
				
			||||||
        mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
 | 
					        mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
 | 
				
			||||||
        path = music_views.get_file_path(cover)
 | 
					        path = music_views.get_file_path(cover)
 | 
				
			||||||
        file_header = mapping[settings.REVERSE_PROXY_TYPE]
 | 
					        file_header = mapping[settings.REVERSE_PROXY_TYPE]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,3 +46,28 @@ def test_get_moderation_url(factory_name, factories, expected):
 | 
				
			||||||
    obj = factories[factory_name]()
 | 
					    obj = factories[factory_name]()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert obj.get_moderation_url() == expected.format(obj=obj)
 | 
					    assert obj.get_moderation_url() == expected.format(obj=obj)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment(factories, now):
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"]()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert attachment.uuid is not None
 | 
				
			||||||
 | 
					    assert attachment.mimetype == "image/jpeg"
 | 
				
			||||||
 | 
					    assert attachment.file is not None
 | 
				
			||||||
 | 
					    assert attachment.url is not None
 | 
				
			||||||
 | 
					    assert attachment.actor is not None
 | 
				
			||||||
 | 
					    assert attachment.creation_date > now
 | 
				
			||||||
 | 
					    assert attachment.last_fetch_date is None
 | 
				
			||||||
 | 
					    assert attachment.size > 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.parametrize("args, expected", [([], [0]), ([True], [0]), ([False], [1])])
 | 
				
			||||||
 | 
					def test_attachment_queryset_attached(args, expected, factories, queryset_equal_list):
 | 
				
			||||||
 | 
					    attachments = [
 | 
				
			||||||
 | 
					        factories["music.Album"]().attachment_cover,
 | 
				
			||||||
 | 
					        factories["common.Attachment"](),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    queryset = attachments[0].__class__.objects.attached(*args).order_by("id")
 | 
				
			||||||
 | 
					    expected_objs = [attachments[i] for i in expected]
 | 
				
			||||||
 | 
					    assert queryset == expected_objs
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,11 +2,13 @@ import os
 | 
				
			||||||
import PIL
 | 
					import PIL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.files.uploadedfile import SimpleUploadedFile
 | 
					from django.core.files.uploadedfile import SimpleUploadedFile
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import django_filters
 | 
					import django_filters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.common import serializers
 | 
					from funkwhale_api.common import serializers
 | 
				
			||||||
from funkwhale_api.users import models
 | 
					from funkwhale_api.users import models
 | 
				
			||||||
 | 
					from funkwhale_api.federation import utils as federation_utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestActionFilterSet(django_filters.FilterSet):
 | 
					class TestActionFilterSet(django_filters.FilterSet):
 | 
				
			||||||
| 
						 | 
					@ -182,3 +184,71 @@ def test_strip_exif_field():
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    cleaned = PIL.Image.open(field.to_internal_value(uploaded))
 | 
					    cleaned = PIL.Image.open(field.to_internal_value(uploaded))
 | 
				
			||||||
    assert cleaned._getexif() is None
 | 
					    assert cleaned._getexif() is None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment_serializer_existing_file(factories, to_api_date):
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"]()
 | 
				
			||||||
 | 
					    expected = {
 | 
				
			||||||
 | 
					        "uuid": str(attachment.uuid),
 | 
				
			||||||
 | 
					        "size": attachment.size,
 | 
				
			||||||
 | 
					        "mimetype": attachment.mimetype,
 | 
				
			||||||
 | 
					        "creation_date": to_api_date(attachment.creation_date),
 | 
				
			||||||
 | 
					        "urls": {
 | 
				
			||||||
 | 
					            "source": attachment.url,
 | 
				
			||||||
 | 
					            "original": federation_utils.full_url(attachment.file.url),
 | 
				
			||||||
 | 
					            "medium_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					                attachment.file.crop["200x200"].url
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        # XXX: BACKWARD COMPATIBILITY
 | 
				
			||||||
 | 
					        "original": federation_utils.full_url(attachment.file.url),
 | 
				
			||||||
 | 
					        "medium_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					            attachment.file.crop["200x200"].url
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        "small_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					            attachment.file.crop["200x200"].url
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        "square_crop": federation_utils.full_url(attachment.file.crop["200x200"].url),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    serializer = serializers.AttachmentSerializer(attachment)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert serializer.data == expected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment_serializer_remote_file(factories, to_api_date):
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"](file=None)
 | 
				
			||||||
 | 
					    proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": attachment.uuid})
 | 
				
			||||||
 | 
					    expected = {
 | 
				
			||||||
 | 
					        "uuid": str(attachment.uuid),
 | 
				
			||||||
 | 
					        "size": attachment.size,
 | 
				
			||||||
 | 
					        "mimetype": attachment.mimetype,
 | 
				
			||||||
 | 
					        "creation_date": to_api_date(attachment.creation_date),
 | 
				
			||||||
 | 
					        # everything is the same, except for the urls field because:
 | 
				
			||||||
 | 
					        #  - the file isn't available on the local pod
 | 
				
			||||||
 | 
					        #  - we need to return different URLs so that the client can trigger
 | 
				
			||||||
 | 
					        #    a fetch and get redirected to the desired version
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        "urls": {
 | 
				
			||||||
 | 
					            "source": attachment.url,
 | 
				
			||||||
 | 
					            "original": federation_utils.full_url(proxy_url + "?next=original"),
 | 
				
			||||||
 | 
					            "medium_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					                proxy_url + "?next=medium_square_crop"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        # XXX: BACKWARD COMPATIBILITY
 | 
				
			||||||
 | 
					        "original": federation_utils.full_url(proxy_url + "?next=original"),
 | 
				
			||||||
 | 
					        "medium_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					            proxy_url + "?next=medium_square_crop"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        "square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					            proxy_url + "?next=medium_square_crop"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        "small_square_crop": federation_utils.full_url(
 | 
				
			||||||
 | 
					            proxy_url + "?next=medium_square_crop"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    serializer = serializers.AttachmentSerializer(attachment)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert serializer.data == expected
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,5 @@
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					import datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.common import serializers
 | 
					from funkwhale_api.common import serializers
 | 
				
			||||||
from funkwhale_api.common import signals
 | 
					from funkwhale_api.common import signals
 | 
				
			||||||
| 
						 | 
					@ -63,3 +64,25 @@ def test_cannot_apply_already_applied_migration(factories):
 | 
				
			||||||
    mutation = factories["common.Mutation"](payload={}, is_applied=True)
 | 
					    mutation = factories["common.Mutation"](payload={}, is_applied=True)
 | 
				
			||||||
    with pytest.raises(mutation.__class__.DoesNotExist):
 | 
					    with pytest.raises(mutation.__class__.DoesNotExist):
 | 
				
			||||||
        tasks.apply_mutation(mutation_id=mutation.pk)
 | 
					        tasks.apply_mutation(mutation_id=mutation.pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_prune_unattached_attachments(factories, settings, now):
 | 
				
			||||||
 | 
					    settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY = 5
 | 
				
			||||||
 | 
					    attachments = [
 | 
				
			||||||
 | 
					        # attached, kept
 | 
				
			||||||
 | 
					        factories["music.Album"]().attachment_cover,
 | 
				
			||||||
 | 
					        # recent, kept
 | 
				
			||||||
 | 
					        factories["common.Attachment"](),
 | 
				
			||||||
 | 
					        # too old, pruned
 | 
				
			||||||
 | 
					        factories["common.Attachment"](
 | 
				
			||||||
 | 
					            creation_date=now
 | 
				
			||||||
 | 
					            - datetime.timedelta(seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY)
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tasks.prune_unattached_attachments()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    attachments[0].refresh_from_db()
 | 
				
			||||||
 | 
					    attachments[1].refresh_from_db()
 | 
				
			||||||
 | 
					    with pytest.raises(attachments[2].DoesNotExist):
 | 
				
			||||||
 | 
					        attachments[2].refresh_from_db()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,6 @@
 | 
				
			||||||
 | 
					import io
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.common import serializers
 | 
					from funkwhale_api.common import serializers
 | 
				
			||||||
| 
						 | 
					@ -181,3 +183,69 @@ def test_rate_limit(logged_in_api_client, now_time, settings, mocker):
 | 
				
			||||||
    assert response.status_code == 200
 | 
					    assert response.status_code == 200
 | 
				
			||||||
    assert response.data == expected
 | 
					    assert response.data == expected
 | 
				
			||||||
    get_status.assert_called_once_with(expected_ident, now_time)
 | 
					    get_status.assert_called_once_with(expected_ident, now_time)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.parametrize(
 | 
				
			||||||
 | 
					    "next, expected",
 | 
				
			||||||
 | 
					    [
 | 
				
			||||||
 | 
					        ("original", "original"),
 | 
				
			||||||
 | 
					        ("medium_square_crop", "medium_square_crop"),
 | 
				
			||||||
 | 
					        ("unknown", "original"),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					def test_attachment_proxy_redirects_original(
 | 
				
			||||||
 | 
					    next, expected, factories, logged_in_api_client, mocker, avatar, r_mock, now
 | 
				
			||||||
 | 
					):
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"](file=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    avatar_content = avatar.read()
 | 
				
			||||||
 | 
					    fetch_remote_attachment = mocker.spy(tasks, "fetch_remote_attachment")
 | 
				
			||||||
 | 
					    m = r_mock.get(attachment.url, body=io.BytesIO(avatar_content))
 | 
				
			||||||
 | 
					    proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": attachment.uuid})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    response = logged_in_api_client.get(proxy_url, {"next": next})
 | 
				
			||||||
 | 
					    attachment.refresh_from_db()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    urls = serializers.AttachmentSerializer(attachment).data["urls"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert attachment.file.read() == avatar_content
 | 
				
			||||||
 | 
					    assert attachment.last_fetch_date == now
 | 
				
			||||||
 | 
					    fetch_remote_attachment.assert_called_once_with(attachment)
 | 
				
			||||||
 | 
					    assert len(m.request_history) == 1
 | 
				
			||||||
 | 
					    assert response.status_code == 302
 | 
				
			||||||
 | 
					    assert response["Location"] == urls[expected]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment_create(logged_in_api_client, avatar):
 | 
				
			||||||
 | 
					    actor = logged_in_api_client.user.create_actor()
 | 
				
			||||||
 | 
					    url = reverse("api:v1:attachments-list")
 | 
				
			||||||
 | 
					    content = avatar.read()
 | 
				
			||||||
 | 
					    avatar.seek(0)
 | 
				
			||||||
 | 
					    payload = {"file": avatar}
 | 
				
			||||||
 | 
					    response = logged_in_api_client.post(url, payload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert response.status_code == 201
 | 
				
			||||||
 | 
					    attachment = actor.attachments.latest("id")
 | 
				
			||||||
 | 
					    assert attachment.file.read() == content
 | 
				
			||||||
 | 
					    assert attachment.file.size == len(content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment_destroy(factories, logged_in_api_client):
 | 
				
			||||||
 | 
					    actor = logged_in_api_client.user.create_actor()
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"](actor=actor)
 | 
				
			||||||
 | 
					    url = reverse("api:v1:attachments-detail", kwargs={"uuid": attachment.uuid})
 | 
				
			||||||
 | 
					    response = logged_in_api_client.delete(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert response.status_code == 204
 | 
				
			||||||
 | 
					    with pytest.raises(attachment.DoesNotExist):
 | 
				
			||||||
 | 
					        attachment.refresh_from_db()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_attachment_destroy_not_owner(factories, logged_in_api_client):
 | 
				
			||||||
 | 
					    logged_in_api_client.user.create_actor()
 | 
				
			||||||
 | 
					    attachment = factories["common.Attachment"]()
 | 
				
			||||||
 | 
					    url = reverse("api:v1:attachments-detail", kwargs={"uuid": attachment.uuid})
 | 
				
			||||||
 | 
					    response = logged_in_api_client.delete(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert response.status_code == 403
 | 
				
			||||||
 | 
					    attachment.refresh_from_db()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -589,7 +589,7 @@ def test_activity_pub_album_serializer_to_ap(factories):
 | 
				
			||||||
        "cover": {
 | 
					        "cover": {
 | 
				
			||||||
            "type": "Link",
 | 
					            "type": "Link",
 | 
				
			||||||
            "mediaType": "image/jpeg",
 | 
					            "mediaType": "image/jpeg",
 | 
				
			||||||
            "href": utils.full_url(album.cover.url),
 | 
					            "href": utils.full_url(album.attachment_cover.file.url),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "musicbrainzId": album.mbid,
 | 
					        "musicbrainzId": album.mbid,
 | 
				
			||||||
        "published": album.creation_date.isoformat(),
 | 
					        "published": album.creation_date.isoformat(),
 | 
				
			||||||
| 
						 | 
					@ -729,8 +729,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
 | 
				
			||||||
    assert str(track.mbid) == data["musicbrainzId"]
 | 
					    assert str(track.mbid) == data["musicbrainzId"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert album.from_activity == activity
 | 
					    assert album.from_activity == activity
 | 
				
			||||||
    assert album.cover.read() == b"coucou"
 | 
					    assert album.attachment_cover.file.read() == b"coucou"
 | 
				
			||||||
    assert album.cover_path.endswith(".png")
 | 
					    assert album.attachment_cover.file.path.endswith(".png")
 | 
				
			||||||
    assert album.title == data["album"]["name"]
 | 
					    assert album.title == data["album"]["name"]
 | 
				
			||||||
    assert album.fid == data["album"]["id"]
 | 
					    assert album.fid == data["album"]["id"]
 | 
				
			||||||
    assert str(album.mbid) == data["album"]["musicbrainzId"]
 | 
					    assert str(album.mbid) == data["album"]["musicbrainzId"]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,8 @@
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from funkwhale_api.manage import serializers
 | 
					from funkwhale_api.common import serializers as common_serializers
 | 
				
			||||||
from funkwhale_api.federation import tasks as federation_tasks
 | 
					from funkwhale_api.federation import tasks as federation_tasks
 | 
				
			||||||
 | 
					from funkwhale_api.manage import serializers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_manage_upload_action_delete(factories):
 | 
					def test_manage_upload_action_delete(factories):
 | 
				
			||||||
| 
						 | 
					@ -339,12 +340,7 @@ def test_manage_nested_album_serializer(factories, now, to_api_date):
 | 
				
			||||||
        "mbid": album.mbid,
 | 
					        "mbid": album.mbid,
 | 
				
			||||||
        "creation_date": to_api_date(album.creation_date),
 | 
					        "creation_date": to_api_date(album.creation_date),
 | 
				
			||||||
        "release_date": album.release_date.isoformat(),
 | 
					        "release_date": album.release_date.isoformat(),
 | 
				
			||||||
        "cover": {
 | 
					        "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
 | 
				
			||||||
            "original": album.cover.url,
 | 
					 | 
				
			||||||
            "square_crop": album.cover.crop["400x400"].url,
 | 
					 | 
				
			||||||
            "medium_square_crop": album.cover.crop["200x200"].url,
 | 
					 | 
				
			||||||
            "small_square_crop": album.cover.crop["50x50"].url,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "tracks_count": 44,
 | 
					        "tracks_count": 44,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    s = serializers.ManageNestedAlbumSerializer(album)
 | 
					    s = serializers.ManageNestedAlbumSerializer(album)
 | 
				
			||||||
| 
						 | 
					@ -380,12 +376,7 @@ def test_manage_album_serializer(factories, now, to_api_date):
 | 
				
			||||||
        "mbid": album.mbid,
 | 
					        "mbid": album.mbid,
 | 
				
			||||||
        "creation_date": to_api_date(album.creation_date),
 | 
					        "creation_date": to_api_date(album.creation_date),
 | 
				
			||||||
        "release_date": album.release_date.isoformat(),
 | 
					        "release_date": album.release_date.isoformat(),
 | 
				
			||||||
        "cover": {
 | 
					        "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
 | 
				
			||||||
            "original": album.cover.url,
 | 
					 | 
				
			||||||
            "square_crop": album.cover.crop["400x400"].url,
 | 
					 | 
				
			||||||
            "medium_square_crop": album.cover.crop["200x200"].url,
 | 
					 | 
				
			||||||
            "small_square_crop": album.cover.crop["50x50"].url,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "artist": serializers.ManageNestedArtistSerializer(album.artist).data,
 | 
					        "artist": serializers.ManageNestedArtistSerializer(album.artist).data,
 | 
				
			||||||
        "tracks": [serializers.ManageNestedTrackSerializer(track).data],
 | 
					        "tracks": [serializers.ManageNestedTrackSerializer(track).data],
 | 
				
			||||||
        "attributed_to": serializers.ManageBaseActorSerializer(
 | 
					        "attributed_to": serializers.ManageBaseActorSerializer(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -192,7 +192,7 @@ def test_album_get_image_content(factories):
 | 
				
			||||||
    album.get_image(data={"content": b"test", "mimetype": "image/jpeg"})
 | 
					    album.get_image(data={"content": b"test", "mimetype": "image/jpeg"})
 | 
				
			||||||
    album.refresh_from_db()
 | 
					    album.refresh_from_db()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert album.cover.read() == b"test"
 | 
					    assert album.attachment_cover.file.read() == b"test"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_library(factories):
 | 
					def test_library(factories):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -132,11 +132,11 @@ def test_can_download_image_file_for_album(binary_cover, mocker, factories):
 | 
				
			||||||
    album.get_image()
 | 
					    album.get_image()
 | 
				
			||||||
    album.save()
 | 
					    album.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert album.cover.file.read() == binary_cover
 | 
					    assert album.attachment_cover.file.read() == binary_cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_album_get_image_doesnt_crash_with_empty_data(mocker, factories):
 | 
					def test_album_get_image_doesnt_crash_with_empty_data(mocker, factories):
 | 
				
			||||||
    album = factories["music.Album"](mbid=None, cover=None)
 | 
					    album = factories["music.Album"](mbid=None, attachment_cover=None)
 | 
				
			||||||
    assert (
 | 
					    assert (
 | 
				
			||||||
        album.get_image(data={"content": "", "url": "", "mimetype": "image/png"})
 | 
					        album.get_image(data={"content": "", "url": "", "mimetype": "image/png"})
 | 
				
			||||||
        is None
 | 
					        is None
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from funkwhale_api.common import serializers as common_serializers
 | 
				
			||||||
from funkwhale_api.federation import serializers as federation_serializers
 | 
					from funkwhale_api.federation import serializers as federation_serializers
 | 
				
			||||||
from funkwhale_api.music import licenses
 | 
					from funkwhale_api.music import licenses
 | 
				
			||||||
from funkwhale_api.music import models
 | 
					from funkwhale_api.music import models
 | 
				
			||||||
| 
						 | 
					@ -42,12 +43,7 @@ def test_artist_album_serializer(factories, to_api_date):
 | 
				
			||||||
        "creation_date": to_api_date(album.creation_date),
 | 
					        "creation_date": to_api_date(album.creation_date),
 | 
				
			||||||
        "tracks_count": 1,
 | 
					        "tracks_count": 1,
 | 
				
			||||||
        "is_playable": None,
 | 
					        "is_playable": None,
 | 
				
			||||||
        "cover": {
 | 
					        "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
 | 
				
			||||||
            "original": album.cover.url,
 | 
					 | 
				
			||||||
            "square_crop": album.cover.crop["400x400"].url,
 | 
					 | 
				
			||||||
            "medium_square_crop": album.cover.crop["200x200"].url,
 | 
					 | 
				
			||||||
            "small_square_crop": album.cover.crop["50x50"].url,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "release_date": to_api_date(album.release_date),
 | 
					        "release_date": to_api_date(album.release_date),
 | 
				
			||||||
        "is_local": album.is_local,
 | 
					        "is_local": album.is_local,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -172,12 +168,7 @@ def test_album_serializer(factories, to_api_date):
 | 
				
			||||||
        "artist": serializers.serialize_artist_simple(album.artist),
 | 
					        "artist": serializers.serialize_artist_simple(album.artist),
 | 
				
			||||||
        "creation_date": to_api_date(album.creation_date),
 | 
					        "creation_date": to_api_date(album.creation_date),
 | 
				
			||||||
        "is_playable": False,
 | 
					        "is_playable": False,
 | 
				
			||||||
        "cover": {
 | 
					        "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
 | 
				
			||||||
            "original": album.cover.url,
 | 
					 | 
				
			||||||
            "square_crop": album.cover.crop["400x400"].url,
 | 
					 | 
				
			||||||
            "medium_square_crop": album.cover.crop["200x200"].url,
 | 
					 | 
				
			||||||
            "small_square_crop": album.cover.crop["50x50"].url,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "release_date": to_api_date(album.release_date),
 | 
					        "release_date": to_api_date(album.release_date),
 | 
				
			||||||
        "tracks": [serializers.serialize_album_track(t) for t in [track2, track1]],
 | 
					        "tracks": [serializers.serialize_album_track(t) for t in [track2, track1]],
 | 
				
			||||||
        "is_local": album.is_local,
 | 
					        "is_local": album.is_local,
 | 
				
			||||||
| 
						 | 
					@ -189,6 +180,15 @@ def test_album_serializer(factories, to_api_date):
 | 
				
			||||||
    assert serializer.data == expected
 | 
					    assert serializer.data == expected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_album_serializer_empty_cover(factories, to_api_date):
 | 
				
			||||||
 | 
					    # XXX: BACKWARD COMPATIBILITY
 | 
				
			||||||
 | 
					    album = factories["music.Album"](attachment_cover=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    serializer = serializers.AlbumSerializer(album)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert serializer.data["cover"] == {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_track_serializer(factories, to_api_date):
 | 
					def test_track_serializer(factories, to_api_date):
 | 
				
			||||||
    actor = factories["federation.Actor"]()
 | 
					    actor = factories["federation.Actor"]()
 | 
				
			||||||
    upload = factories["music.Upload"](
 | 
					    upload = factories["music.Upload"](
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,9 +49,7 @@ def test_library_track(spa_html, no_api_auth, client, factories, settings):
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "meta",
 | 
					            "tag": "meta",
 | 
				
			||||||
            "property": "og:image",
 | 
					            "property": "og:image",
 | 
				
			||||||
            "content": utils.join_url(
 | 
					            "content": track.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                settings.FUNKWHALE_URL, track.album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "meta",
 | 
					            "tag": "meta",
 | 
				
			||||||
| 
						 | 
					@ -116,9 +114,7 @@ def test_library_album(spa_html, no_api_auth, client, factories, settings):
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "meta",
 | 
					            "tag": "meta",
 | 
				
			||||||
            "property": "og:image",
 | 
					            "property": "og:image",
 | 
				
			||||||
            "content": utils.join_url(
 | 
					            "content": album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                settings.FUNKWHALE_URL, album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "link",
 | 
					            "tag": "link",
 | 
				
			||||||
| 
						 | 
					@ -166,9 +162,7 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "meta",
 | 
					            "tag": "meta",
 | 
				
			||||||
            "property": "og:image",
 | 
					            "property": "og:image",
 | 
				
			||||||
            "content": utils.join_url(
 | 
					            "content": album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                settings.FUNKWHALE_URL, album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "link",
 | 
					            "tag": "link",
 | 
				
			||||||
| 
						 | 
					@ -217,9 +211,7 @@ def test_library_playlist(spa_html, no_api_auth, client, factories, settings):
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "meta",
 | 
					            "tag": "meta",
 | 
				
			||||||
            "property": "og:image",
 | 
					            "property": "og:image",
 | 
				
			||||||
            "content": utils.join_url(
 | 
					            "content": track.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
                settings.FUNKWHALE_URL, track.album.cover.crop["400x400"].url
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "tag": "link",
 | 
					            "tag": "link",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -285,8 +285,11 @@ def test_can_create_track_from_file_metadata_federation(factories, mocker, r_moc
 | 
				
			||||||
    assert track.fid == metadata["fid"]
 | 
					    assert track.fid == metadata["fid"]
 | 
				
			||||||
    assert track.creation_date == metadata["fdate"]
 | 
					    assert track.creation_date == metadata["fdate"]
 | 
				
			||||||
    assert track.position == 4
 | 
					    assert track.position == 4
 | 
				
			||||||
    assert track.album.cover.read() == b"coucou"
 | 
					    assert track.album.attachment_cover.file.read() == b"coucou"
 | 
				
			||||||
    assert track.album.cover_path.endswith(".png")
 | 
					    assert track.album.attachment_cover.file.path.endswith(".png")
 | 
				
			||||||
 | 
					    assert track.album.attachment_cover.url == metadata["cover_data"]["url"]
 | 
				
			||||||
 | 
					    assert track.album.attachment_cover.mimetype == metadata["cover_data"]["mimetype"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert track.album.fid == metadata["album"]["fid"]
 | 
					    assert track.album.fid == metadata["album"]["fid"]
 | 
				
			||||||
    assert track.album.title == metadata["album"]["title"]
 | 
					    assert track.album.title == metadata["album"]["title"]
 | 
				
			||||||
    assert track.album.creation_date == metadata["album"]["fdate"]
 | 
					    assert track.album.creation_date == metadata["album"]["fdate"]
 | 
				
			||||||
| 
						 | 
					@ -312,7 +315,7 @@ def test_upload_import(now, factories, temp_signal, mocker):
 | 
				
			||||||
    update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
 | 
					    update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
 | 
				
			||||||
    get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
 | 
					    get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
 | 
				
			||||||
    get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
 | 
					    get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
 | 
				
			||||||
    track = factories["music.Track"](album__cover="")
 | 
					    track = factories["music.Track"](album__attachment_cover=None)
 | 
				
			||||||
    upload = factories["music.Upload"](
 | 
					    upload = factories["music.Upload"](
 | 
				
			||||||
        track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}}
 | 
					        track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}}
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
| 
						 | 
					@ -531,7 +534,7 @@ def test_upload_import_error_metadata(factories, now, temp_signal, mocker):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_upload_import_updates_cover_if_no_cover(factories, mocker, now):
 | 
					def test_upload_import_updates_cover_if_no_cover(factories, mocker, now):
 | 
				
			||||||
    mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
 | 
					    mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
 | 
				
			||||||
    album = factories["music.Album"](cover="")
 | 
					    album = factories["music.Album"](attachment_cover=None)
 | 
				
			||||||
    track = factories["music.Track"](album=album)
 | 
					    track = factories["music.Track"](album=album)
 | 
				
			||||||
    upload = factories["music.Upload"](
 | 
					    upload = factories["music.Upload"](
 | 
				
			||||||
        track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}
 | 
					        track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}
 | 
				
			||||||
| 
						 | 
					@ -541,7 +544,7 @@ def test_upload_import_updates_cover_if_no_cover(factories, mocker, now):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_update_album_cover_mbid(factories, mocker):
 | 
					def test_update_album_cover_mbid(factories, mocker):
 | 
				
			||||||
    album = factories["music.Album"](cover="")
 | 
					    album = factories["music.Album"](attachment_cover=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
					    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
				
			||||||
    tasks.update_album_cover(album=album)
 | 
					    tasks.update_album_cover(album=album)
 | 
				
			||||||
| 
						 | 
					@ -550,7 +553,7 @@ def test_update_album_cover_mbid(factories, mocker):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_update_album_cover_file_data(factories, mocker):
 | 
					def test_update_album_cover_file_data(factories, mocker):
 | 
				
			||||||
    album = factories["music.Album"](cover="", mbid=None)
 | 
					    album = factories["music.Album"](attachment_cover=None, mbid=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
					    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
				
			||||||
    tasks.update_album_cover(album=album, cover_data={"hello": "world"})
 | 
					    tasks.update_album_cover(album=album, cover_data={"hello": "world"})
 | 
				
			||||||
| 
						 | 
					@ -563,7 +566,7 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m
 | 
				
			||||||
    image_path = os.path.join(DATA_DIR, "cover.{}".format(ext))
 | 
					    image_path = os.path.join(DATA_DIR, "cover.{}".format(ext))
 | 
				
			||||||
    with open(image_path, "rb") as f:
 | 
					    with open(image_path, "rb") as f:
 | 
				
			||||||
        image_content = f.read()
 | 
					        image_content = f.read()
 | 
				
			||||||
    album = factories["music.Album"](cover="", mbid=None)
 | 
					    album = factories["music.Album"](attachment_cover=None, mbid=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
					    mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image")
 | 
				
			||||||
    mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None)
 | 
					    mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -780,10 +780,10 @@ def test_oembed_track(factories, no_api_auth, api_client, settings):
 | 
				
			||||||
        "title": "{} by {}".format(track.title, track.artist.name),
 | 
					        "title": "{} by {}".format(track.title, track.artist.name),
 | 
				
			||||||
        "description": track.full_name,
 | 
					        "description": track.full_name,
 | 
				
			||||||
        "thumbnail_url": federation_utils.full_url(
 | 
					        "thumbnail_url": federation_utils.full_url(
 | 
				
			||||||
            track.album.cover.crop["400x400"].url
 | 
					            track.album.attachment_cover.file.crop["200x200"].url
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        "thumbnail_height": 400,
 | 
					        "thumbnail_height": 200,
 | 
				
			||||||
        "thumbnail_width": 400,
 | 
					        "thumbnail_width": 200,
 | 
				
			||||||
        "html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
					        "html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
				
			||||||
            iframe_src
 | 
					            iframe_src
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
| 
						 | 
					@ -815,9 +815,11 @@ def test_oembed_album(factories, no_api_auth, api_client, settings):
 | 
				
			||||||
        "width": 600,
 | 
					        "width": 600,
 | 
				
			||||||
        "title": "{} by {}".format(album.title, album.artist.name),
 | 
					        "title": "{} by {}".format(album.title, album.artist.name),
 | 
				
			||||||
        "description": "{} by {}".format(album.title, album.artist.name),
 | 
					        "description": "{} by {}".format(album.title, album.artist.name),
 | 
				
			||||||
        "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url),
 | 
					        "thumbnail_url": federation_utils.full_url(
 | 
				
			||||||
        "thumbnail_height": 400,
 | 
					            album.attachment_cover.file.crop["200x200"].url
 | 
				
			||||||
        "thumbnail_width": 400,
 | 
					        ),
 | 
				
			||||||
 | 
					        "thumbnail_height": 200,
 | 
				
			||||||
 | 
					        "thumbnail_width": 200,
 | 
				
			||||||
        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
					        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
				
			||||||
            iframe_src
 | 
					            iframe_src
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
| 
						 | 
					@ -850,9 +852,11 @@ def test_oembed_artist(factories, no_api_auth, api_client, settings):
 | 
				
			||||||
        "width": 600,
 | 
					        "width": 600,
 | 
				
			||||||
        "title": artist.name,
 | 
					        "title": artist.name,
 | 
				
			||||||
        "description": artist.name,
 | 
					        "description": artist.name,
 | 
				
			||||||
        "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url),
 | 
					        "thumbnail_url": federation_utils.full_url(
 | 
				
			||||||
        "thumbnail_height": 400,
 | 
					            album.attachment_cover.file.crop["200x200"].url
 | 
				
			||||||
        "thumbnail_width": 400,
 | 
					        ),
 | 
				
			||||||
 | 
					        "thumbnail_height": 200,
 | 
				
			||||||
 | 
					        "thumbnail_width": 200,
 | 
				
			||||||
        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
					        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
				
			||||||
            iframe_src
 | 
					            iframe_src
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
| 
						 | 
					@ -886,10 +890,10 @@ def test_oembed_playlist(factories, no_api_auth, api_client, settings):
 | 
				
			||||||
        "title": playlist.name,
 | 
					        "title": playlist.name,
 | 
				
			||||||
        "description": playlist.name,
 | 
					        "description": playlist.name,
 | 
				
			||||||
        "thumbnail_url": federation_utils.full_url(
 | 
					        "thumbnail_url": federation_utils.full_url(
 | 
				
			||||||
            track.album.cover.crop["400x400"].url
 | 
					            track.album.attachment_cover.file.crop["200x200"].url
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        "thumbnail_height": 400,
 | 
					        "thumbnail_height": 200,
 | 
				
			||||||
        "thumbnail_width": 400,
 | 
					        "thumbnail_width": 200,
 | 
				
			||||||
        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
					        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
 | 
				
			||||||
            iframe_src
 | 
					            iframe_src
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -95,7 +95,7 @@ def test_playlist_serializer_include_covers(factories, api_request):
 | 
				
			||||||
    playlist = factories["playlists.Playlist"]()
 | 
					    playlist = factories["playlists.Playlist"]()
 | 
				
			||||||
    t1 = factories["music.Track"]()
 | 
					    t1 = factories["music.Track"]()
 | 
				
			||||||
    t2 = factories["music.Track"]()
 | 
					    t2 = factories["music.Track"]()
 | 
				
			||||||
    t3 = factories["music.Track"](album__cover=None)
 | 
					    t3 = factories["music.Track"](album__attachment_cover=None)
 | 
				
			||||||
    t4 = factories["music.Track"]()
 | 
					    t4 = factories["music.Track"]()
 | 
				
			||||||
    t5 = factories["music.Track"]()
 | 
					    t5 = factories["music.Track"]()
 | 
				
			||||||
    t6 = factories["music.Track"]()
 | 
					    t6 = factories["music.Track"]()
 | 
				
			||||||
| 
						 | 
					@ -106,11 +106,11 @@ def test_playlist_serializer_include_covers(factories, api_request):
 | 
				
			||||||
    qs = playlist.__class__.objects.with_covers().with_tracks_count()
 | 
					    qs = playlist.__class__.objects.with_covers().with_tracks_count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expected = [
 | 
					    expected = [
 | 
				
			||||||
        request.build_absolute_uri(t1.album.cover.crop["200x200"].url),
 | 
					        t1.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
        request.build_absolute_uri(t2.album.cover.crop["200x200"].url),
 | 
					        t2.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
        request.build_absolute_uri(t4.album.cover.crop["200x200"].url),
 | 
					        t4.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
        request.build_absolute_uri(t5.album.cover.crop["200x200"].url),
 | 
					        t5.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
        request.build_absolute_uri(t6.album.cover.crop["200x200"].url),
 | 
					        t6.album.attachment_cover.download_url_medium_square_crop,
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request})
 | 
					    serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request})
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -724,7 +724,7 @@ def test_get_cover_art_album(factories, logged_in_api_client):
 | 
				
			||||||
    assert response.status_code == 200
 | 
					    assert response.status_code == 200
 | 
				
			||||||
    assert response["Content-Type"] == ""
 | 
					    assert response["Content-Type"] == ""
 | 
				
			||||||
    assert response["X-Accel-Redirect"] == music_views.get_file_path(
 | 
					    assert response["X-Accel-Redirect"] == music_views.get_file_path(
 | 
				
			||||||
        album.cover
 | 
					        album.attachment_cover.file
 | 
				
			||||||
    ).decode("utf-8")
 | 
					    ).decode("utf-8")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -185,6 +185,8 @@ tags:
 | 
				
			||||||
      url: https://docs.funkwhale.audio/users/managing.html
 | 
					      url: https://docs.funkwhale.audio/users/managing.html
 | 
				
			||||||
  - name: Content curation
 | 
					  - name: Content curation
 | 
				
			||||||
    description: Favorites, playlists, radios
 | 
					    description: Favorites, playlists, radios
 | 
				
			||||||
 | 
					  - name: Other
 | 
				
			||||||
 | 
					    description: Other endpoints that don't fit in the categories above
 | 
				
			||||||
 | 
					
 | 
				
			||||||
paths:
 | 
					paths:
 | 
				
			||||||
  /api/v1/oauth/apps/:
 | 
					  /api/v1/oauth/apps/:
 | 
				
			||||||
| 
						 | 
					@ -1022,6 +1024,54 @@ paths:
 | 
				
			||||||
        204:
 | 
					        204:
 | 
				
			||||||
          $ref: "#/responses/204"
 | 
					          $ref: "#/responses/204"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /api/v1/attachments/:
 | 
				
			||||||
 | 
					    post:
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - "Other"
 | 
				
			||||||
 | 
					      description:
 | 
				
			||||||
 | 
					        Upload a new file as an attachment that can be later associated with other objects.
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        201:
 | 
				
			||||||
 | 
					          $ref: "#/responses/201"
 | 
				
			||||||
 | 
					        400:
 | 
				
			||||||
 | 
					          $ref: "#/responses/400"
 | 
				
			||||||
 | 
					      requestBody:
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					        content:
 | 
				
			||||||
 | 
					          multipart/form-data:
 | 
				
			||||||
 | 
					            schema:
 | 
				
			||||||
 | 
					              type: object
 | 
				
			||||||
 | 
					              properties:
 | 
				
			||||||
 | 
					                file:
 | 
				
			||||||
 | 
					                  type: string
 | 
				
			||||||
 | 
					                  format: binary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /api/v1/attachments/{uuid}/:
 | 
				
			||||||
 | 
					    parameters:
 | 
				
			||||||
 | 
					      - name: uuid
 | 
				
			||||||
 | 
					        in: path
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					        schema:
 | 
				
			||||||
 | 
					          type: "string"
 | 
				
			||||||
 | 
					          format: "uuid"
 | 
				
			||||||
 | 
					    get:
 | 
				
			||||||
 | 
					      summary: Retrieve an attachment
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - "Other"
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        200:
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            application/json:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                $ref: "#/definitions/Attachment"
 | 
				
			||||||
 | 
					    delete:
 | 
				
			||||||
 | 
					      summary: Delete an attachment
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - "Other"
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        204:
 | 
				
			||||||
 | 
					          $ref: "#/responses/204"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
parameters:
 | 
					parameters:
 | 
				
			||||||
  ObjectId:
 | 
					  ObjectId:
 | 
				
			||||||
    name: id
 | 
					    name: id
 | 
				
			||||||
| 
						 | 
					@ -1114,6 +1164,12 @@ properties:
 | 
				
			||||||
      - "audio/mpeg"
 | 
					      - "audio/mpeg"
 | 
				
			||||||
      - "audio/x-flac"
 | 
					      - "audio/x-flac"
 | 
				
			||||||
      - "audio/flac"
 | 
					      - "audio/flac"
 | 
				
			||||||
 | 
					  image_mimetype:
 | 
				
			||||||
 | 
					    type: string
 | 
				
			||||||
 | 
					    example: "image/png"
 | 
				
			||||||
 | 
					    enum:
 | 
				
			||||||
 | 
					      - "image/png"
 | 
				
			||||||
 | 
					      - "image/jpeg"
 | 
				
			||||||
  import_status:
 | 
					  import_status:
 | 
				
			||||||
    type: string
 | 
					    type: string
 | 
				
			||||||
    example: "finished"
 | 
					    example: "finished"
 | 
				
			||||||
| 
						 | 
					@ -1180,28 +1236,33 @@ definitions:
 | 
				
			||||||
        format: "uri"
 | 
					        format: "uri"
 | 
				
			||||||
        description: "Link to the previous page of results"
 | 
					        description: "Link to the previous page of results"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Attachment:
 | 
				
			||||||
  Image:
 | 
					    type: "object"
 | 
				
			||||||
 | 
					    properties:
 | 
				
			||||||
 | 
					      uuid:
 | 
				
			||||||
 | 
					        type: string
 | 
				
			||||||
 | 
					        format: uuid
 | 
				
			||||||
 | 
					      size:
 | 
				
			||||||
 | 
					        type: "integer"
 | 
				
			||||||
 | 
					        format: "int64"
 | 
				
			||||||
 | 
					        example: 2787000
 | 
				
			||||||
 | 
					        description: "Size of the file, in bytes"
 | 
				
			||||||
 | 
					      mimetype:
 | 
				
			||||||
 | 
					        $ref: "#/properties/image_mimetype"
 | 
				
			||||||
 | 
					      creation_date:
 | 
				
			||||||
 | 
					        type: "string"
 | 
				
			||||||
 | 
					        format: "date-time"
 | 
				
			||||||
 | 
					      urls:
 | 
				
			||||||
        type: "object"
 | 
					        type: "object"
 | 
				
			||||||
        properties:
 | 
					        properties:
 | 
				
			||||||
          original:
 | 
					          original:
 | 
				
			||||||
            type: "string"
 | 
					            type: "string"
 | 
				
			||||||
            description: "URL to the original image"
 | 
					            description: "URL to the original image"
 | 
				
			||||||
        example: "https://mydomain/media/albums/covers/ec2c53aeaac6.jpg"
 | 
					            example: "https://mydomain/media/attachments/ec2c53aeaac6.jpg"
 | 
				
			||||||
      small_square_crop:
 | 
					 | 
				
			||||||
        type: "string"
 | 
					 | 
				
			||||||
        description: "URL to a small, squared thumbnail of the image"
 | 
					 | 
				
			||||||
        example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-50x50-70.jpg"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          medium_square_crop:
 | 
					          medium_square_crop:
 | 
				
			||||||
            type: "string"
 | 
					            type: "string"
 | 
				
			||||||
            description: "URL to a medium, squared thumbnail of the image"
 | 
					            description: "URL to a medium, squared thumbnail of the image"
 | 
				
			||||||
        example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-200x200-70.jpg"
 | 
					            example: "https://mydomain/media/__sized__/attachments/ec2c53aeaac6-crop-c0-5__0-5-200x200-70.jpg"
 | 
				
			||||||
 | 
					 | 
				
			||||||
      square_crop:
 | 
					 | 
				
			||||||
        type: "string"
 | 
					 | 
				
			||||||
        description: "URL to a large, squared thumbnail of the image"
 | 
					 | 
				
			||||||
        example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-400x400-70.jpg"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Actor:
 | 
					  Actor:
 | 
				
			||||||
    type: object
 | 
					    type: object
 | 
				
			||||||
| 
						 | 
					@ -1317,7 +1378,7 @@ definitions:
 | 
				
			||||||
      is_playable:
 | 
					      is_playable:
 | 
				
			||||||
        type: "boolean"
 | 
					        type: "boolean"
 | 
				
			||||||
      cover:
 | 
					      cover:
 | 
				
			||||||
        $ref: "#/definitions/Image"
 | 
					        $ref: "#/definitions/Attachment"
 | 
				
			||||||
      is_local:
 | 
					      is_local:
 | 
				
			||||||
        type: "boolean"
 | 
					        type: "boolean"
 | 
				
			||||||
        description: "Indicates if the object was initally created locally or on another server"
 | 
					        description: "Indicates if the object was initally created locally or on another server"
 | 
				
			||||||
| 
						 | 
					@ -1508,7 +1569,7 @@ definitions:
 | 
				
			||||||
      uuid:
 | 
					      uuid:
 | 
				
			||||||
        type: string
 | 
					        type: string
 | 
				
			||||||
        format: uuid
 | 
					        format: uuid
 | 
				
			||||||
      size:
 | 
					      size:size:
 | 
				
			||||||
        type: "integer"
 | 
					        type: "integer"
 | 
				
			||||||
        format: "int64"
 | 
					        format: "int64"
 | 
				
			||||||
        example: 278987000
 | 
					        example: 278987000
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue