Attachments

This commit is contained in:
Eliot Berriot 2019-11-25 09:49:06 +01:00
parent 421b441dbe
commit c84396e669
50 changed files with 879 additions and 261 deletions

View File

@ -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)

View File

@ -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

View File

@ -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"]

View File

@ -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"

View File

@ -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)

View File

@ -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)),
],
),
]

View File

@ -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()

View File

@ -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):

View File

@ -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"]
)

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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(),

View File

@ -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:

View File

@ -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()

View File

@ -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)

View File

@ -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",

View File

@ -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"

View File

@ -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)

View File

@ -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)
) )

View File

@ -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()

View File

@ -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")

View File

@ -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),
),
]

View File

@ -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)]

View File

@ -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()

View File

@ -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

View File

@ -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,
),
} }
) )

View File

@ -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,

View File

@ -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]

View File

@ -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")
) )

View File

@ -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)

View File

@ -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:

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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"]

View File

@ -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(

View File

@ -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):

View File

@ -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

View File

@ -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"](

View File

@ -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",

View File

@ -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)

View File

@ -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
), ),

View File

@ -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})

View File

@ -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")

View File

@ -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" type: "object"
properties: properties:
original: 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" type: "string"
description: "URL to the original image" format: "date-time"
example: "https://mydomain/media/albums/covers/ec2c53aeaac6.jpg" urls:
small_square_crop: type: "object"
type: "string" properties:
description: "URL to a small, squared thumbnail of the image" original:
example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-50x50-70.jpg" type: "string"
description: "URL to the original image"
medium_square_crop: example: "https://mydomain/media/attachments/ec2c53aeaac6.jpg"
type: "string" medium_square_crop:
description: "URL to a medium, squared thumbnail of the image" type: "string"
example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-200x200-70.jpg" description: "URL to a medium, squared thumbnail of the image"
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