0.17 release documentation initial draft and migration script

environments/review-docs-funkw-78jnxn/deployments/34
Eliot Berriot 2018-09-25 20:18:02 +00:00
rodzic 98591b2ac4
commit b6e376ed0a
14 zmienionych plików z 683 dodań i 106 usunięć

Wyświetl plik

@ -1,64 +1,155 @@
"""
Mirate instance files to a library #463. For each user that imported music on an
instance, we will create a "default" library with related files and an instance-level
visibility.
visibility (unless instance has common__api_authentication_required set to False,
in which case the libraries will be public).
Files without any import job will be bounded to a "default" library on the first
superuser account found. This should now happen though.
XXX TODO:
This command will also generate federation ids for existing resources.
- add followers url on actor
- shared inbox url on actor
- compute hash from files
"""
from django.conf import settings
from django.db.models import functions, CharField, F, Value
from funkwhale_api.music import models
from funkwhale_api.users.models import User
from funkwhale_api.federation import models as federation_models
from funkwhale_api.common import preferences
def create_libraries(open_api, stdout):
local_actors = federation_models.Actor.objects.exclude(user=None).only("pk", "user")
privacy_level = "everyone" if open_api else "instance"
stdout.write(
"* Creating {} libraries with {} visibility".format(
len(local_actors), privacy_level
)
)
libraries_by_user = {}
for a in local_actors:
library, created = models.Library.objects.get_or_create(
name="default", actor=a, defaults={"privacy_level": privacy_level}
)
libraries_by_user[library.actor.user.pk] = library.pk
if created:
stdout.write(
" * Created library {} for user {}".format(library.pk, a.user.pk)
)
else:
stdout.write(
" * Found existing library {} for user {}".format(
library.pk, a.user.pk
)
)
return libraries_by_user
def update_uploads(libraries_by_user, stdout):
stdout.write("* Updating uploads with proper libraries...")
for user_id, library_id in libraries_by_user.items():
jobs = models.ImportJob.objects.filter(
upload__library=None, batch__submitted_by=user_id
)
candidates = models.Upload.objects.filter(
pk__in=jobs.values_list("upload", flat=True)
)
total = candidates.update(library=library_id, import_status="finished")
if total:
stdout.write(
" * Assigned {} uploads to user {}'s library".format(total, user_id)
)
else:
stdout.write(
" * No uploads to assign to user {}'s library".format(user_id)
)
def update_orphan_uploads(open_api, stdout):
privacy_level = "everyone" if open_api else "instance"
first_superuser = User.objects.filter(is_superuser=True).order_by("pk").first()
library, _ = models.Library.objects.get_or_create(
name="default",
actor=first_superuser.actor,
defaults={"privacy_level": privacy_level},
)
candidates = (
models.Upload.objects.filter(library=None, jobs__isnull=True)
.exclude(audio_file=None)
.exclude(audio_file="")
)
total = candidates.update(library=library, import_status="finished")
if total:
stdout.write(
"* Assigned {} orphaned uploads to superuser {}".format(
total, first_superuser.pk
)
)
else:
stdout.write("* No orphaned uploads found")
def set_fid(queryset, path, stdout):
model = queryset.model._meta.label
qs = queryset.filter(fid=None)
base_url = "{}{}".format(settings.FUNKWHALE_URL, path)
stdout.write(
"* Assigning federation ids to {} entries (path: {})".format(model, base_url)
)
new_fid = functions.Concat(Value(base_url), F("uuid"), output_field=CharField())
total = qs.update(fid=new_fid)
stdout.write(" * {} entries updated".format(total))
def update_shared_inbox_url(stdout):
stdout.write("* Update shared inbox url for local actors...")
candidates = federation_models.Actor.objects.local().filter(shared_inbox_url=None)
url = federation_models.get_shared_inbox_url()
candidates.update(shared_inbox_url=url)
def generate_actor_urls(part, stdout):
field = "{}_url".format(part)
stdout.write("* Update {} for local actors...".format(field))
queryset = federation_models.Actor.objects.local().filter(**{field: None})
base_url = "{}/federation/actors/".format(settings.FUNKWHALE_URL)
new_field = functions.Concat(
Value(base_url),
F("preferred_username"),
Value("/{}".format(part)),
output_field=CharField(),
)
queryset.update(**{field: new_field})
def main(command, **kwargs):
importer_ids = set(
models.ImportBatch.objects.values_list("submitted_by", flat=True)
)
importers = User.objects.filter(pk__in=importer_ids).order_by("id").select_related()
command.stdout.write(
"* {} users imported music on this instance".format(len(importers))
)
files = models.Upload.objects.filter(
library__isnull=True, jobs__isnull=False
).distinct()
command.stdout.write(
"* Reassigning {} files to importers libraries...".format(files.count())
)
for user in importers:
command.stdout.write(
" * Setting up @{}'s 'default' library".format(user.username)
)
library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[
0
]
user_files = files.filter(jobs__batch__submitted_by=user)
total = user_files.count()
command.stdout.write(
" * Reassigning {} files to the user library...".format(total)
)
user_files.update(library=library)
open_api = not preferences.get("common__api_authentication_required")
libraries_by_user = create_libraries(open_api, command.stdout)
update_uploads(libraries_by_user, command.stdout)
update_orphan_uploads(open_api, command.stdout)
files = models.Upload.objects.filter(
library__isnull=True, jobs__isnull=True
).distinct()
command.stdout.write(
"* Handling {} files with no import jobs...".format(files.count())
)
set_fid_params = [
(
models.Upload.objects.exclude(library__actor__user=None),
"/federation/music/uploads/",
),
(models.Artist.objects.all(), "/federation/music/artists/"),
(models.Album.objects.all(), "/federation/music/albums/"),
(models.Track.objects.all(), "/federation/music/tracks/"),
]
for qs, path in set_fid_params:
set_fid(qs, path, command.stdout)
user = User.objects.order_by("id").filter(is_superuser=True).first()
update_shared_inbox_url(command.stdout)
command.stdout.write(" * Setting up @{}'s 'default' library".format(user.username))
library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[0]
total = files.count()
command.stdout.write(
" * Reassigning {} files to the user library...".format(total)
)
files.update(library=library)
command.stdout.write(" * Done!")
for part in ["followers", "following"]:
generate_actor_urls(part, command.stdout)

Wyświetl plik

@ -9,6 +9,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils
@ -29,6 +30,10 @@ def empty_dict():
return {}
def get_shared_inbox_url():
return federation_utils.full_url(reverse("federation:shared-inbox"))
class FederationMixin(models.Model):
# federation id/url
fid = models.URLField(unique=True, max_length=500, db_index=True)

Wyświetl plik

@ -4,6 +4,7 @@ import factory
from funkwhale_api.factories import ManyToManyFromList, registry
from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.users import factories as users_factories
SAMPLES_PATH = os.path.join(
@ -100,3 +101,24 @@ class TagFactory(factory.django.DjangoModelFactory):
class Meta:
model = "taggit.Tag"
# XXX To remove
class ImportBatchFactory(factory.django.DjangoModelFactory):
submitted_by = factory.SubFactory(users_factories.UserFactory)
class Meta:
model = "music.ImportBatch"
@registry.register
class ImportJobFactory(factory.django.DjangoModelFactory):
batch = factory.SubFactory(ImportBatchFactory)
source = factory.Faker("url")
mbid = factory.Faker("uuid4")
replace_if_duplicate = False
class Meta:
model = "music.ImportJob"

Wyświetl plik

@ -557,8 +557,8 @@ class UploadQuerySet(models.QuerySet):
libraries = Library.objects.viewable_by(actor)
if include:
return self.filter(library__in=libraries)
return self.exclude(library__in=libraries)
return self.filter(library__in=libraries, import_status="finished")
return self.exclude(library__in=libraries, import_status="finished")
def local(self, include=True):
return self.exclude(library__actor__user__isnull=include)
@ -899,7 +899,7 @@ class Library(federation_models.FederationMixin):
)
def save(self, **kwargs):
if not self.pk and not self.fid and self.actor.is_local:
if not self.pk and not self.fid and self.actor.get_user():
self.fid = self.get_federation_id()
self.followers_url = self.fid + "/followers"

Wyświetl plik

@ -248,10 +248,9 @@ class Invitation(models.Model):
return super().save(**kwargs)
def create_actor(user):
def get_actor_data(user):
username = federation_utils.slugify_username(user.username)
private, public = keys.get_key_pair()
args = {
return {
"preferred_username": username,
"domain": settings.FEDERATION_HOSTNAME,
"type": "Person",
@ -260,9 +259,7 @@ def create_actor(user):
"fid": federation_utils.full_url(
reverse("federation:actors-detail", kwargs={"preferred_username": username})
),
"shared_inbox_url": federation_utils.full_url(
reverse("federation:shared-inbox")
),
"shared_inbox_url": federation_models.get_shared_inbox_url(),
"inbox_url": federation_utils.full_url(
reverse("federation:actors-inbox", kwargs={"preferred_username": username})
),
@ -280,6 +277,11 @@ def create_actor(user):
)
),
}
def create_actor(user):
args = get_actor_data(user)
private, public = keys.get_key_pair()
args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8")

Wyświetl plik

@ -2,6 +2,8 @@ import pytest
from funkwhale_api.common import scripts
from funkwhale_api.common.management.commands import script
from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models
@pytest.fixture
@ -44,29 +46,216 @@ def test_django_permissions_to_user_permissions(factories, command):
assert user2.permission_federation is True
@pytest.mark.skip("Refactoring in progress")
def test_migrate_to_user_libraries(factories, command):
user1 = factories["users.User"](is_superuser=False, with_actor=True)
user2 = factories["users.User"](is_superuser=True, with_actor=True)
factories["users.User"](is_superuser=True)
no_import_files = factories["music.Upload"].create_batch(size=5, library=None)
import_jobs = factories["music.ImportJob"].create_batch(
batch__submitted_by=user1, size=5, finished=True
@pytest.mark.parametrize(
"open_api,expected_visibility", [(True, "everyone"), (False, "instance")]
)
def test_migrate_to_user_libraries_create_libraries(
factories, open_api, expected_visibility, stdout
):
user1 = factories["users.User"](with_actor=True)
user2 = factories["users.User"](with_actor=True)
result = scripts.migrate_to_user_libraries.create_libraries(open_api, stdout)
user1_library = user1.actor.libraries.get(
name="default", privacy_level=expected_visibility
)
# we delete libraries that are created automatically
for j in import_jobs:
j.upload.library = None
j.upload.save()
user2_library = user2.actor.libraries.get(
name="default", privacy_level=expected_visibility
)
assert result == {user1.pk: user1_library.pk, user2.pk: user2_library.pk}
def test_migrate_to_user_libraries_update_uploads(factories, stdout):
user1 = factories["users.User"](with_actor=True)
user2 = factories["users.User"](with_actor=True)
library1 = factories["music.Library"](actor=user1.actor)
library2 = factories["music.Library"](actor=user2.actor)
upload1 = factories["music.Upload"]()
upload2 = factories["music.Upload"]()
# we delete libraries
upload1.library = None
upload2.library = None
upload1.save()
upload2.save()
factories["music.ImportJob"](batch__submitted_by=user1, upload=upload1)
factories["music.ImportJob"](batch__submitted_by=user2, upload=upload2)
libraries_by_user = {user1.pk: library1.pk, user2.pk: library2.pk}
scripts.migrate_to_user_libraries.update_uploads(libraries_by_user, stdout)
upload1.refresh_from_db()
upload2.refresh_from_db()
assert upload1.library == library1
assert upload1.import_status == "finished"
assert upload2.library == library2
assert upload2.import_status == "finished"
@pytest.mark.parametrize(
"open_api,expected_visibility", [(True, "everyone"), (False, "instance")]
)
def test_migrate_to_user_libraries_without_jobs(
factories, open_api, expected_visibility, stdout
):
superuser = factories["users.User"](is_superuser=True, with_actor=True)
upload1 = factories["music.Upload"]()
upload2 = factories["music.Upload"]()
upload3 = factories["music.Upload"](audio_file=None)
# we delete libraries
upload1.library = None
upload2.library = None
upload3.library = None
upload1.save()
upload2.save()
upload3.save()
factories["music.ImportJob"](upload=upload2)
scripts.migrate_to_user_libraries.update_orphan_uploads(open_api, stdout)
upload1.refresh_from_db()
upload2.refresh_from_db()
upload3.refresh_from_db()
superuser_library = superuser.actor.libraries.get(
name="default", privacy_level=expected_visibility
)
assert upload1.library == superuser_library
assert upload1.import_status == "finished"
# left untouched because they don't match filters
assert upload2.library is None
assert upload3.library is None
@pytest.mark.parametrize(
"model,args,path",
[
("music.Upload", {"library__actor__local": True}, "/federation/music/uploads/"),
("music.Artist", {}, "/federation/music/artists/"),
("music.Album", {}, "/federation/music/albums/"),
("music.Track", {}, "/federation/music/tracks/"),
],
)
def test_migrate_to_user_libraries_generate_fids(
factories, args, model, path, settings, stdout
):
template = "{}{}{}"
objects = factories[model].create_batch(5, fid=None, **args)
klass = factories[model]._meta.model
# we leave a fid on the first one, and set the others to None
existing_fid = objects[0].fid
base_path = existing_fid.replace(str(objects[0].uuid), "")
klass.objects.filter(pk__in=[o.pk for o in objects[1:]]).update(fid=None)
scripts.migrate_to_user_libraries.set_fid(klass.objects.all(), path, stdout)
for i, o in enumerate(objects):
o.refresh_from_db()
if i == 0:
assert o.fid == existing_fid
else:
assert o.fid == template.format(settings.FUNKWHALE_URL, path, o.uuid)
# we also ensure the path we insert match the one that is generated
# by the app on objects creation, as a safe guard for typos
assert base_path == o.fid.replace(str(o.uuid), "")
def test_migrate_to_user_libraries_update_actors_shared_inbox_url(factories, stdout):
local = factories["federation.Actor"](local=True, shared_inbox_url=None)
remote = factories["federation.Actor"](local=False, shared_inbox_url=None)
expected = federation_models.get_shared_inbox_url()
scripts.migrate_to_user_libraries.update_shared_inbox_url(stdout)
local.refresh_from_db()
remote.refresh_from_db()
assert local.shared_inbox_url == expected
assert remote.shared_inbox_url is None
@pytest.mark.parametrize("part", ["following", "followers"])
def test_migrate_to_user_libraries_generate_actor_urls(
factories, part, settings, stdout
):
field = "{}_url".format(part)
ok = factories["users.User"]().create_actor()
local = factories["federation.Actor"](local=True, **{field: None})
remote = factories["federation.Actor"](local=False, **{field: None})
assert getattr(local, field) is None
expected = "{}/federation/actors/{}/{}".format(
settings.FUNKWHALE_URL, local.preferred_username, part
)
ok_url = getattr(ok, field)
scripts.migrate_to_user_libraries.generate_actor_urls(part, stdout)
ok.refresh_from_db()
local.refresh_from_db()
remote.refresh_from_db()
# unchanged
assert getattr(ok, field) == ok_url
assert getattr(remote, field) is None
assert getattr(local, field) == expected
assert expected.replace(local.preferred_username, "") == ok_url.replace(
ok.preferred_username, ""
)
def test_migrate_to_users_libraries_command(
preferences, mocker, db, command, queryset_equal_queries
):
preferences["common__api_authentication_required"] = False
open_api = not preferences["common__api_authentication_required"]
create_libraries = mocker.patch.object(
scripts.migrate_to_user_libraries,
"create_libraries",
return_value={"hello": "world"},
)
update_uploads = mocker.patch.object(
scripts.migrate_to_user_libraries, "update_uploads"
)
update_orphan_uploads = mocker.patch.object(
scripts.migrate_to_user_libraries, "update_orphan_uploads"
)
set_fid = mocker.patch.object(scripts.migrate_to_user_libraries, "set_fid")
update_shared_inbox_url = mocker.patch.object(
scripts.migrate_to_user_libraries, "update_shared_inbox_url"
)
generate_actor_urls = mocker.patch.object(
scripts.migrate_to_user_libraries, "generate_actor_urls"
)
scripts.migrate_to_user_libraries.main(command)
# tracks with import jobs are bound to the importer's library
library = user1.actor.libraries.get(name="default")
assert list(library.uploads.order_by("id").values_list("id", flat=True)) == sorted(
[ij.upload.pk for ij in import_jobs]
)
create_libraries.assert_called_once_with(open_api, command.stdout)
update_uploads.assert_called_once_with({"hello": "world"}, command.stdout)
update_orphan_uploads.assert_called_once_with(open_api, command.stdout)
set_fid_params = [
(
music_models.Upload.objects.exclude(library__actor__user=None),
"/federation/music/uploads/",
),
(music_models.Artist.objects.all(), "/federation/music/artists/"),
(music_models.Album.objects.all(), "/federation/music/albums/"),
(music_models.Track.objects.all(), "/federation/music/tracks/"),
]
for qs, path in set_fid_params:
set_fid.assert_any_call(qs, path, command.stdout)
update_shared_inbox_url.assert_called_once_with(command.stdout)
# generate_actor_urls(part, stdout):
# tracks without import jobs are bound to first superuser
library = user2.actor.libraries.get(name="default")
assert list(library.uploads.order_by("id").values_list("id", flat=True)) == sorted(
[upload.pk for upload in no_import_files]
)
for part in ["followers", "following"]:
generate_actor_urls.assert_any_call(part, command.stdout)

Wyświetl plik

@ -377,3 +377,8 @@ def temp_signal(mocker):
signal.disconnect(stub)
return connect
@pytest.fixture()
def stdout():
yield io.StringIO()

Wyświetl plik

@ -41,7 +41,7 @@ def test_upload_url_is_accessible_to_authenticated_users(
):
actor = logged_in_api_client.user.create_actor()
preferences["common__api_authentication_required"] = True
upload = factories["music.Upload"](library__actor=actor)
upload = factories["music.Upload"](library__actor=actor, import_status="finished")
assert upload.audio_file is not None
url = upload.track.listen_url
response = logged_in_api_client.get(url)

Wyświetl plik

@ -208,11 +208,25 @@ def test_library(factories):
assert library.uuid is not None
@pytest.mark.parametrize(
"status,expected", [("pending", False), ("errored", False), ("finished", True)]
)
def test_playable_by_correct_status(status, expected, factories):
upload = factories["music.Upload"](
library__privacy_level="everyone", import_status=status
)
queryset = upload.library.uploads.playable_by(None)
match = upload in list(queryset)
assert match is expected
@pytest.mark.parametrize(
"privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
)
def test_playable_by_correct_actor(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
queryset = upload.library.uploads.playable_by(upload.library.actor)
match = upload in list(queryset)
assert match is expected
@ -222,7 +236,9 @@ def test_playable_by_correct_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
)
def test_playable_by_instance_actor(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
queryset = upload.library.uploads.playable_by(instance_actor)
match = upload in list(queryset)
@ -233,7 +249,9 @@ def test_playable_by_instance_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_playable_by_anonymous(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
queryset = upload.library.uploads.playable_by(None)
match = upload in list(queryset)
assert match is expected
@ -241,7 +259,9 @@ def test_playable_by_anonymous(privacy_level, expected, factories):
@pytest.mark.parametrize("approved", [True, False])
def test_playable_by_follower(approved, factories):
upload = factories["music.Upload"](library__privacy_level="me")
upload = factories["music.Upload"](
library__privacy_level="me", import_status="finished"
)
actor = factories["federation.Actor"](local=True)
factories["federation.LibraryFollow"](
target=upload.library, actor=actor, approved=approved
@ -256,7 +276,7 @@ def test_playable_by_follower(approved, factories):
"privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
)
def test_track_playable_by_correct_actor(privacy_level, expected, factories):
upload = factories["music.Upload"]()
upload = factories["music.Upload"](import_status="finished")
queryset = models.Track.objects.playable_by(
upload.library.actor
).annotate_playable_by_actor(upload.library.actor)
@ -270,7 +290,9 @@ def test_track_playable_by_correct_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
)
def test_track_playable_by_instance_actor(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
queryset = models.Track.objects.playable_by(
instance_actor
@ -285,7 +307,9 @@ def test_track_playable_by_instance_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_track_playable_by_anonymous(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
queryset = models.Track.objects.playable_by(None).annotate_playable_by_actor(None)
match = upload.track in list(queryset)
assert match is expected
@ -297,7 +321,7 @@ def test_track_playable_by_anonymous(privacy_level, expected, factories):
"privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
)
def test_album_playable_by_correct_actor(privacy_level, expected, factories):
upload = factories["music.Upload"]()
upload = factories["music.Upload"](import_status="finished")
queryset = models.Album.objects.playable_by(
upload.library.actor
@ -312,7 +336,9 @@ def test_album_playable_by_correct_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
)
def test_album_playable_by_instance_actor(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
queryset = models.Album.objects.playable_by(
instance_actor
@ -327,7 +353,9 @@ def test_album_playable_by_instance_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_album_playable_by_anonymous(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
queryset = models.Album.objects.playable_by(None).annotate_playable_by_actor(None)
match = upload.track.album in list(queryset)
assert match is expected
@ -339,7 +367,7 @@ def test_album_playable_by_anonymous(privacy_level, expected, factories):
"privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
)
def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
upload = factories["music.Upload"]()
upload = factories["music.Upload"](import_status="finished")
queryset = models.Artist.objects.playable_by(
upload.library.actor
@ -354,7 +382,9 @@ def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
)
def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
instance_actor = factories["federation.Actor"](domain=upload.library.actor.domain)
queryset = models.Artist.objects.playable_by(
instance_actor
@ -369,7 +399,9 @@ def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_artist_playable_by_anonymous(privacy_level, expected, factories):
upload = factories["music.Upload"](library__privacy_level=privacy_level)
upload = factories["music.Upload"](
library__privacy_level=privacy_level, import_status="finished"
)
queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None)
match = upload.track.artist in list(queryset)
assert match is expected

Wyświetl plik

@ -12,7 +12,9 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
def test_artist_list_serializer(api_request, factories, logged_in_api_client):
track = factories["music.Upload"](library__privacy_level="everyone").track
track = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
).track
artist = track.artist
request = api_request.get("/")
qs = artist.__class__.objects.with_albums()
@ -31,7 +33,9 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client):
def test_album_list_serializer(api_request, factories, logged_in_api_client):
track = factories["music.Upload"](library__privacy_level="everyone").track
track = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
).track
album = track.album
request = api_request.get("/")
qs = album.__class__.objects.all()
@ -49,7 +53,9 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client):
def test_track_list_serializer(api_request, factories, logged_in_api_client):
track = factories["music.Upload"](library__privacy_level="everyone").track
track = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
).track
request = api_request.get("/")
qs = track.__class__.objects.all()
serializer = serializers.TrackSerializer(
@ -69,7 +75,7 @@ def test_artist_view_filter_playable(param, expected, factories, api_request):
artists = {
"empty": factories["music.Artist"](),
"full": factories["music.Upload"](
library__privacy_level="everyone"
library__privacy_level="everyone", import_status="finished"
).track.artist,
}
@ -88,7 +94,7 @@ def test_album_view_filter_playable(param, expected, factories, api_request):
artists = {
"empty": factories["music.Album"](),
"full": factories["music.Upload"](
library__privacy_level="everyone"
library__privacy_level="everyone", import_status="finished"
).track.album,
}
@ -106,7 +112,9 @@ def test_can_serve_upload_as_remote_library(
factories, authenticated_actor, logged_in_api_client, settings, preferences
):
preferences["common__api_authentication_required"] = True
upload = factories["music.Upload"](library__privacy_level="everyone")
upload = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
)
library_actor = upload.library.actor
factories["federation.Follow"](
approved=True, actor=authenticated_actor, target=library_actor
@ -124,7 +132,9 @@ def test_can_serve_upload_as_remote_library_deny_not_following(
factories, authenticated_actor, settings, api_client, preferences
):
preferences["common__api_authentication_required"] = True
upload = factories["music.Upload"](library__privacy_level="instance")
upload = factories["music.Upload"](
import_status="finished", library__privacy_level="instance"
)
response = api_client.get(upload.track.listen_url)
assert response.status_code == 404
@ -150,6 +160,7 @@ def test_serve_file_in_place(
settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
upload = factories["music.Upload"](
in_place=True,
import_status="finished",
source="file:///app/music/hello/world.mp3",
library__privacy_level="everyone",
)
@ -202,7 +213,9 @@ def test_serve_file_media(
settings.MUSIC_DIRECTORY_PATH = "/app/music"
settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
upload = factories["music.Upload"](library__privacy_level="everyone")
upload = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
)
upload.__class__.objects.filter(pk=upload.pk).update(
audio_file="tracks/hello/world.mp3"
)
@ -216,7 +229,10 @@ def test_can_proxy_remote_track(factories, settings, api_client, r_mock, prefere
preferences["common__api_authentication_required"] = False
url = "https://file.test"
upload = factories["music.Upload"](
library__privacy_level="everyone", audio_file="", source=url
library__privacy_level="everyone",
audio_file="",
source=url,
import_status="finished",
)
r_mock.get(url, body=io.BytesIO(b"test"))
@ -232,7 +248,9 @@ def test_can_proxy_remote_track(factories, settings, api_client, r_mock, prefere
def test_serve_updates_access_date(factories, settings, api_client, preferences):
preferences["common__api_authentication_required"] = False
upload = factories["music.Upload"](library__privacy_level="everyone")
upload = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
)
now = timezone.now()
assert upload.accessed_date is None
@ -269,7 +287,9 @@ def test_listen_no_available_file(factories, logged_in_api_client):
def test_listen_correct_access(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
upload = factories["music.Upload"](
library__actor=logged_in_api_client.user.actor, library__privacy_level="me"
library__actor=logged_in_api_client.user.actor,
library__privacy_level="me",
import_status="finished",
)
url = reverse("api:v1:listen-detail", kwargs={"uuid": upload.track.uuid})
response = logged_in_api_client.get(url)
@ -279,9 +299,11 @@ def test_listen_correct_access(factories, logged_in_api_client):
def test_listen_explicit_file(factories, logged_in_api_client, mocker):
mocked_serve = mocker.spy(views, "handle_serve")
upload1 = factories["music.Upload"](library__privacy_level="everyone")
upload1 = factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
)
upload2 = factories["music.Upload"](
library__privacy_level="everyone", track=upload1.track
library__privacy_level="everyone", track=upload1.track, import_status="finished"
)
url = reverse("api:v1:listen-detail", kwargs={"uuid": upload2.track.uuid})
response = logged_in_api_client.get(url, {"upload": upload2.uuid})

Wyświetl plik

@ -1,6 +1,6 @@
Upgrade instructions are available at
https://docs.funkwhale.audio/upgrading.html
https://docs.funkwhale.audio/index.html
{% for section, _ in sections.items() %}
{% if sections[section] %}

Wyświetl plik

@ -15,7 +15,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
features
architecture
installation/index
upgrading
upgrading/index
configuration
troubleshooting
importing-music

Wyświetl plik

@ -0,0 +1,197 @@
About Funkwhale 0.17
====================
Funkwhale 0.17 is a special version, which contains a lot of breaking changes.
Before doing the upgrade, please read this document carefully.
Overview of the changes
^^^^^^^^^^^^^^^^^^^^^^^
.. note::
The what and why are described more thoroughly in this page: https://code.eliotberriot.com/funkwhale/funkwhale/merge_requests/368
To sum it up, this release big completely changes the way audio content is managed in Funkwhale.
As you may guess, this has a huge impact on the whole project, because audio is at the
core of Funkwhale.
Here is a side by side comparison of earlier versions and this release
to help you understand the scale of the changes:
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Before | After | Reason |
+========================================================================================+=================================================================================================+=========================================================================================================================================================================================================================================================+
| There is one big audio library, managed at the instance level | Each user can have their own libraries (either public, private or shared at the instance level) | Managing the library at instance was cumbersome and dangerous: sharing an instance library over federation would quickly pose copyright issues, as well as opening public instances. It also made it impossible to only share a subset of the music. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Users needed a specific permissions from instance owners to upload audio content | Users can upload music to their own libraries without any specific permissions | This change makes it easier for new users to start using Funkwhale, and for creators to share their content on the network. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Users with permissions can upload as much content as they want in the instance library | Users have a storage quota and cannot exceed that storage | This change gives visibiliy to instance owners about their resource usage. If you host 100 users with a 1Gb quota, you know that your Funkwhale instance will not store more than 100Gb of music files. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| N/A | Users can upload private content or share content with only specific users | This is a new feature, and we think it will enable users to upload their own music libraries to their instance, without breaking the law or putting their admins in trouble, since their media will remain private. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Youtube Import | This feature is removed | This feature posed copyright issues and impacted the credibility of the project, so we removed it. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Music requests | This feature is removed | Since all users can now upload content without specific permissions, we think this feature is less-likely to be useful in its current state. |
+----------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
From a shared, instance-wide library to users libraries
------------------------------------------------------
As you can see, there is a big switch: in earlier versions, each instance had one big library,
that was available to all its users. This model don't scale well (especially if you put
federation on top of that), because it's an all-or-nothing choice if you want to share it.
Starting from version 0.17, each user will be able to create personal libraries
and upload content in those, up to a configurable quota.
Those libraries can have one of the following visibility level:
- **Private**: only the owner of the library can access its content
- **Instance**: users from the same instance can access the library content
- **Public**: everyone (including other instances) can access the library content
Regardless of this visibility level, library owners can also share them manually
with other users, both from the same instance or from the federation.
We think this change will have a really positive impact:
- Admins should be more encline to open their instance to strangers, because copyrighted media
can be upload and shared privately
- Creators should have a better experience when joining the network, because they can now
upload their own content and share it over the federation without any admin intervention
- The federation should grow faster, because user libraries can contain copyrighted content
and be shared, without putting the admins at risk
Accessing music
---------------
From an end-user perspective, you will be able to browse any artist or album or track
that is known by your instance, but you'll only be able to listen to content
that match one of those critaeria:
- The content is available is one of your libraries
- The content is available in a public library
- The content is available in one library from your instance that has a visibility level set to "instance"
- The content is available in one of the library you follow
Following someone else's library is a four step process:
1. Get the library link from its owner
2. Use this link on your instance to follow the library
3. Wait until your follow request is approved by the library owner
4. If this library is unknown on your instance, it will be scanned to import its content, which may take a few minutes
Libraries owner can revoke follows at any time, which will effectively prevent
the ancient follower from accessing the library content.
A brand new federation
----------------------
This is more "under the hood" work, but the whole federation/ActivityPub logic
was rewritten for this release. This new implementation is more spec compliant
and should scale better.
The following activities are propagated over federation:
- Library follow creation, accept and reject
- Audio creation and deletion
- Library deletion
A better import UI
------------------
This version includes a completely new import UI which should make
file uploading less annoying. Especially it's updating in real-time
and has a better error reporting.
A Better import engine
----------------------
Funkwhale is known for its quircks during music import. Missing covers,
splitted albums, bad management of tracks with multiple artists, missing
data for files imported over federation, bad performance, discrepencies between
the user provided tags and what is actually stored in the database...
This should be greatly improved now, as the whole import logic was rewritten
from scratch.
Import is done completely offline and do not call the MusicBrainz API anymore,
except to retrieve covers if those are not embedded in the imported files.
MusicBrainzare references are still stored in the database, but we rely solely
on the tags from the audio file now.
This has two positive consequences:
- Improved performance for both small and big imports (possibly by a factor 10)
- More reliable import result: if your file is tagged in a specific way, we will only
use tags for the import.
Imports from federation, command-line and UI/API all use the same code,
which should greatly reduce the bugs/discrepencies.
Finally, the import engine now understand the difference between a track artist
and an album artist, which should put an end to the album splitting issues
for tracks that had a different artist than the album artist.
What will break
---------------
If you've read until here, you can probably understand that all of these changes
comes at a cost: version 0.17 contains breaking changes, feature were removed
or changed.
Those features were removed:
- YouTube imports: for copyright reasons, keeping this in the core was not possible
- Music requests: those are now less useful since anyone can upload content
Also, the current federation will break, as it's absolutely not compatible
with what we've built in version 0.17, and maintaining compatibility was simply not possible.
Apart from that, other features should work the same way as they did before.
Migration path
--------------
.. warning::
This migration is huge. Do a backup. Please. The database, and the music files.
Please.
.. warning:: I'm not kidding.
Migration will be similar to previous ones, with an additional script to run that will
take care of updating existing rows in the database. Especially, this script
will be responsible to create a library for each registered user, and to
bind content imported by each one to this library.
Libraries created this way will have a different visibility level depending of your instance configuration:
- If your instance requires authentication to access the API / Listen to music, libraries will
be marked with "instance" visibility. As a result, all users from the instance will still
be able to listen to all the music of the instance after the migration
- If your instance does not requires authentication to access the API / Listen to music,
libraries will be completely public, allowing anyone to access the content (including federation)
This script will contain other database-related operations, but the impact will remain
invisible.
Upgrade instructions
--------------------
Follow instructions from https://docs.funkwhale.audio/upgrading/index.html,
then run the migrations script.
On docker-setups::
docker-compose run --rm api python manage.py script migrate_to_user_libraries --no-input
On non docker-setups::
python api/manage.py script migrate_to_user_libraries --no-input
If the scripts ends without errors, you're instance should be updated and ready to use :)

Wyświetl plik

@ -18,6 +18,18 @@ similarly from version to version, but some of them may require additional steps
Those steps would be described in the version release notes.
Insights about new versions
---------------------------
Some versions may be bigger than usual, and we'll try to detail the changes
when possible.
.. toctree::
:maxdepth: 1
0.17
Docker setup
------------