Merge branch 'federation-upgrade' into 'develop'
0.17 release documentation initial draft and migration script See merge request funkwhale/funkwhale!418
This commit is contained in:
commit
84ed7e0b3a
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -377,3 +377,8 @@ def temp_signal(mocker):
|
|||
signal.disconnect(stub)
|
||||
|
||||
return connect
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def stdout():
|
||||
yield io.StringIO()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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] %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 :)
|
|
@ -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
|
||||
------------
|
||||
|
Loading…
Reference in New Issue