From 3e49b2057a23d1301d91b9d78a3291cc44593b80 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Sat, 22 Sep 2018 15:47:17 +0000 Subject: [PATCH] CLI for importing files with user libraries --- api/funkwhale_api/music/serializers.py | 2 +- api/funkwhale_api/music/tasks.py | 11 +- api/funkwhale_api/music/views.py | 2 +- .../management/commands/import_files.py | 132 +++++++++++------ api/tests/music/test_serializers.py | 2 +- api/tests/music/test_tasks.py | 12 +- api/tests/music/test_views.py | 2 +- api/tests/test_import_audio_file.py | 137 +++++++++--------- demo/setup.sh | 6 +- 9 files changed, 177 insertions(+), 129 deletions(-) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 39d9bf249..a23fc1daa 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -341,7 +341,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer): pks = list(qs.values_list("id", flat=True)) qs.update(import_status="pending") for pk in pks: - common_utils.on_commit(tasks.import_upload.delay, upload_id=pk) + common_utils.on_commit(tasks.process_upload.delay, upload_id=pk) class TagSerializer(serializers.ModelSerializer): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index c800289f4..61ee15585 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -225,7 +225,7 @@ def scan_library_page(library_scan, page_url): if upload.import_status == "pending" and not upload.track: # this track is not matched to any musicbrainz or other musical # metadata - import_upload.delay(upload_id=upload.pk) + process_upload.delay(upload_id=upload.pk) uploads.append(upload) library_scan.processed_files = F("processed_files") + len(uploads) @@ -249,7 +249,10 @@ def getter(data, *keys): return v = data for k in keys: - v = v.get(k) + try: + v = v[k] + except KeyError: + return return v @@ -274,14 +277,14 @@ def fail_import(upload, error_code): ) -@celery.app.task(name="music.import_upload") +@celery.app.task(name="music.process_upload") @celery.require_instance( models.Upload.objects.filter(import_status="pending").select_related( "library__actor__user" ), "upload", ) -def import_upload(upload): +def process_upload(upload): data = upload.import_metadata or {} old_status = upload.import_status try: diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 6888a4ab6..5c92ad2b1 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -350,7 +350,7 @@ class UploadViewSet( def perform_create(self, serializer): upload = serializer.save() - common_utils.on_commit(tasks.import_upload.delay, upload_id=upload.pk) + common_utils.on_commit(tasks.process_upload.delay, upload_id=upload.pk) @transaction.atomic def perform_destroy(self, instance): diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index 2707b4c9a..bc1c9af0a 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -1,18 +1,29 @@ import glob import os +import urllib.parse from django.conf import settings from django.core.files import File from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone from funkwhale_api.music import models, tasks -from funkwhale_api.users.models import User class Command(BaseCommand): help = "Import audio files mathinc given glob pattern" def add_arguments(self, parser): + parser.add_argument( + "library_id", + type=str, + help=( + "A local library identifier where the files should be imported. " + "You can use the full uuid such as e29c5be9-6da3-4d92-b40b-4970edd3ee4b " + "or only a small portion of it, starting from the beginning, such as " + "e29c5be9" + ), + ) parser.add_argument("path", nargs="+", type=str) parser.add_argument( "--recursive", @@ -29,7 +40,7 @@ class Command(BaseCommand): parser.add_argument( "--async", action="store_true", - dest="async", + dest="async_", default=False, help="Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI", ) @@ -66,6 +77,17 @@ class Command(BaseCommand): "with their newest version." ), ) + + parser.add_argument( + "--reference", + action="store", + dest="reference", + default=None, + help=( + "A custom reference for the import. Leave this empty to have a random " + "reference being generated for you." + ), + ) parser.add_argument( "--noinput", "--no-input", @@ -77,14 +99,22 @@ class Command(BaseCommand): def handle(self, *args, **options): glob_kwargs = {} matching = [] + + try: + library = models.Library.objects.select_related("actor__user").get( + uuid__startswith=options["library_id"] + ) + except models.Library.DoesNotExist: + raise CommandError("Invalid library id") + + if not library.actor.is_local: + raise CommandError("Library {} is not a local library".format(library.uuid)) + if options["recursive"]: glob_kwargs["recursive"] = True - try: - for import_path in options["path"]: - matching += glob.glob(import_path, **glob_kwargs) - raw_matching = sorted(list(set(matching))) - except TypeError: - raise Exception("You need Python 3.5 to use the --recursive flag") + for import_path in options["path"]: + matching += glob.glob(import_path, **glob_kwargs) + raw_matching = sorted(list(set(matching))) matching = [] for m in raw_matching: @@ -128,28 +158,12 @@ class Command(BaseCommand): if not matching: raise CommandError("No file matching pattern, aborting") - user = None - if options["username"]: - try: - user = User.objects.get(username=options["username"]) - except User.DoesNotExist: - raise CommandError("Invalid username") - else: - # we bind the import to the first registered superuser - try: - user = User.objects.filter(is_superuser=True).order_by("pk").first() - assert user is not None - except AssertionError: - raise CommandError( - "No superuser available, please provide a --username" - ) - if options["replace"]: filtered = {"initial": matching, "skipped": [], "new": matching} message = "- {} files to be replaced" import_paths = matching else: - filtered = self.filter_matching(matching) + filtered = self.filter_matching(matching, library) message = "- {} files already found in database" import_paths = filtered["new"] @@ -179,10 +193,26 @@ class Command(BaseCommand): ) if input("".join(message)) != "yes": raise CommandError("Import cancelled.") + reference = options["reference"] or "cli-{}".format(timezone.now().isoformat()) + + import_url = "{}://{}/content/libraries/{}/upload?{}" + import_url = import_url.format( + settings.FUNKWHALE_PROTOCOL, + settings.FUNKWHALE_HOSTNAME, + str(library.uuid), + urllib.parse.urlencode([("import", reference)]), + ) + self.stdout.write( + "For details, please refer to import refrence '{}' or URL {}".format( + reference, import_url + ) + ) - batch, errors = self.do_import(import_paths, user=user, options=options) + errors = self.do_import( + import_paths, library=library, reference=reference, options=options + ) message = "Successfully imported {} tracks" - if options["async"]: + if options["async_"]: message = "Successfully launched import for {} tracks" self.stdout.write(message.format(len(import_paths))) @@ -191,15 +221,18 @@ class Command(BaseCommand): for path, error in errors: self.stderr.write("- {}: {}".format(path, error)) + self.stdout.write( - "For details, please refer to import batch #{}".format(batch.pk) + "For details, please refer to import refrence '{}' or URL {}".format( + reference, import_url + ) ) - def filter_matching(self, matching): + def filter_matching(self, matching, library): sources = ["file://{}".format(p) for p in matching] # we skip reimport for path that are already found # as a Upload.source - existing = models.Upload.objects.filter(source__in=sources) + existing = library.uploads.filter(source__in=sources, import_status="finished") existing = existing.values_list("source", flat=True) existing = set([p.replace("file://", "", 1) for p in existing]) skipped = set(matching) & existing @@ -210,20 +243,25 @@ class Command(BaseCommand): } return result - def do_import(self, paths, user, options): + def do_import(self, paths, library, reference, options): message = "{i}/{total} Importing {path}..." - if options["async"]: + if options["async_"]: message = "{i}/{total} Launching import for {path}..." - # we create an import batch binded to the user - async_ = options["async"] - import_handler = tasks.import_job_run.delay if async_ else tasks.import_job_run - batch = user.imports.create(source="shell") + # we create an upload binded to the library + async_ = options["async_"] errors = [] for i, path in list(enumerate(paths)): try: self.stdout.write(message.format(path=path, i=i + 1, total=len(paths))) - self.import_file(path, batch, import_handler, options) + self.create_upload( + path, + reference, + library, + async_, + options["replace"], + options["in_place"], + ) except Exception as e: if options["exit_on_failure"]: raise @@ -232,16 +270,18 @@ class Command(BaseCommand): ) self.stderr.write(m) errors.append((path, "{} {}".format(e.__class__.__name__, e))) - return batch, errors + return errors - def import_file(self, path, batch, import_handler, options): - job = batch.jobs.create( - source="file://" + path, replace_if_duplicate=options["replace"] - ) - if not options["in_place"]: + def create_upload(self, path, reference, library, async_, replace, in_place): + import_handler = tasks.process_upload.delay if async_ else tasks.process_upload + upload = models.Upload(library=library, import_reference=reference) + upload.source = "file://" + path + upload.import_metadata = {"replace": replace} + if not in_place: name = os.path.basename(path) with open(path, "rb") as f: - job.audio_file.save(name, File(f)) + upload.audio_file.save(name, File(f), save=False) + + upload.save() - job.save() - import_handler(import_job_id=job.pk, use_acoustid=False) + import_handler(upload_id=upload.pk) diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 0da8aabce..330371834 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -255,7 +255,7 @@ def test_manage_upload_action_relaunch_import(factories, mocker): for obj in to_relaunch: obj.refresh_from_db() assert obj.import_status == "pending" - m.assert_any_call(tasks.import_upload.delay, upload_id=obj.pk) + m.assert_any_call(tasks.process_upload.delay, upload_id=obj.pk) finished.refresh_from_db() assert finished.import_status == "finished" diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 75cb2f0a6..c58bce7db 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -97,7 +97,7 @@ def test_upload_import_mbid(now, factories, temp_signal, mocker): ) with temp_signal(signals.upload_import_status_updated) as handler: - tasks.import_upload(upload_id=upload.pk) + tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() @@ -126,7 +126,7 @@ def test_upload_import_get_audio_data(factories, mocker): track=None, import_metadata={"track": {"mbid": track.mbid}} ) - tasks.import_upload(upload_id=upload.pk) + tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() assert upload.size == 23 @@ -150,7 +150,7 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal import_metadata={"track": {"mbid": track.mbid}}, ) with temp_signal(signals.upload_import_status_updated) as handler: - tasks.import_upload(upload_id=duplicate.pk) + tasks.process_upload(upload_id=duplicate.pk) duplicate.refresh_from_db() @@ -175,7 +175,7 @@ def test_upload_import_track_uuid(now, factories): track=None, import_metadata={"track": {"uuid": track.uuid}} ) - tasks.import_upload(upload_id=upload.pk) + tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() @@ -189,7 +189,7 @@ def test_upload_import_error(factories, now, temp_signal): import_metadata={"track": {"uuid": uuid.uuid4()}} ) with temp_signal(signals.upload_import_status_updated) as handler: - tasks.import_upload(upload_id=upload.pk) + tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() assert upload.import_status == "errored" @@ -211,7 +211,7 @@ def test_upload_import_updates_cover_if_no_cover(factories, mocker, now): upload = factories["music.Upload"]( track=None, import_metadata={"track": {"uuid": track.uuid}} ) - tasks.import_upload(upload_id=upload.pk) + tasks.process_upload(upload_id=upload.pk) mocked_update.assert_called_once_with(album, upload) diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 30b084d39..8f994b236 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -407,7 +407,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f assert upload.source == "upload://test" assert upload.import_reference == "test" assert upload.track is None - m.assert_called_once_with(tasks.import_upload.delay, upload_id=upload.pk) + m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) def test_user_can_list_own_library_follows(factories, logged_in_api_client): diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index faa13fa3b..a7b2380ed 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -4,121 +4,124 @@ import pytest from django.core.management import call_command from django.core.management.base import CommandError -from funkwhale_api.music.models import ImportJob -DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") -@pytest.mark.skip("XXX : wip") -def test_management_command_requires_a_valid_username(factories, mocker): +def test_management_command_requires_a_valid_library_id(factories): path = os.path.join(DATA_DIR, "dummy_file.ogg") - factories["users.User"](username="me") - mocker.patch( - "funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import", # noqa - return_value=(mocker.MagicMock(), []), - ) - with pytest.raises(CommandError): - call_command("import_files", path, username="not_me", interactive=False) - call_command("import_files", path, username="me", interactive=False) + + with pytest.raises(CommandError) as e: + call_command("import_files", "wrong_id", path, interactive=False) + assert "Invalid library id" in str(e) def test_in_place_import_only_from_music_dir(factories, settings): - factories["users.User"](username="me") + library = factories["music.Library"](actor__local=True) settings.MUSIC_DIRECTORY_PATH = "/nope" path = os.path.join(DATA_DIR, "dummy_file.ogg") - with pytest.raises(CommandError): + with pytest.raises(CommandError) as e: call_command( - "import_files", path, in_place=True, username="me", interactive=False + "import_files", str(library.uuid), path, in_place=True, interactive=False ) + assert "Importing in-place only works if importing" in str(e) + -@pytest.mark.skip("XXX : wip") def test_import_with_multiple_argument(factories, mocker): - factories["users.User"](username="me") + library = factories["music.Library"](actor__local=True) path1 = os.path.join(DATA_DIR, "dummy_file.ogg") path2 = os.path.join(DATA_DIR, "utf8-éà ◌.ogg") mocked_filter = mocker.patch( "funkwhale_api.providers.audiofile.management.commands.import_files.Command.filter_matching", return_value=({"new": [], "skipped": []}), ) - call_command("import_files", path1, path2, username="me", interactive=False) - mocked_filter.assert_called_once_with([path1, path2]) + call_command("import_files", str(library.uuid), path1, path2, interactive=False) + mocked_filter.assert_called_once_with([path1, path2], library) + + +@pytest.mark.parametrize( + "path", + [os.path.join(DATA_DIR, "dummy_file.ogg"), os.path.join(DATA_DIR, "utf8-éà ◌.ogg")], +) +def test_import_files_stores_proper_data(factories, mocker, now, path): + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + library = factories["music.Library"](actor__local=True) + call_command( + "import_files", str(library.uuid), path, async_=False, interactive=False + ) + upload = library.uploads.last() + assert upload.import_reference == "cli-{}".format(now.isoformat()) + assert upload.import_status == "pending" + assert upload.source == "file://{}".format(path) + + mocked_process.assert_called_once_with(upload_id=upload.pk) -@pytest.mark.skip("Refactoring in progress") def test_import_with_replace_flag(factories, mocker): - factories["users.User"](username="me") + library = factories["music.Library"](actor__local=True) path = os.path.join(DATA_DIR, "dummy_file.ogg") - mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run") - call_command("import_files", path, username="me", replace=True, interactive=False) - created_job = ImportJob.objects.latest("id") - - assert created_job.replace_if_duplicate is True - mocked_job_run.assert_called_once_with( - import_job_id=created_job.id, use_acoustid=False + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", str(library.uuid), path, replace=True, interactive=False ) + upload = library.uploads.last() + assert upload.import_metadata["replace"] is True -@pytest.mark.skip("Refactoring in progress") -def test_import_files_creates_a_batch_and_job(factories, mocker): - m = mocker.patch("funkwhale_api.music.tasks.import_job_run") - user = factories["users.User"](username="me") - path = os.path.join(DATA_DIR, "dummy_file.ogg") - call_command("import_files", path, username="me", async=False, interactive=False) + mocked_process.assert_called_once_with(upload_id=upload.pk) - batch = user.imports.latest("id") - assert batch.source == "shell" - assert batch.jobs.count() == 1 - job = batch.jobs.first() +def test_import_with_custom_reference(factories, mocker): + library = factories["music.Library"](actor__local=True) + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", + str(library.uuid), + path, + reference="test", + replace=True, + interactive=False, + ) + upload = library.uploads.last() - assert job.status == "pending" - with open(path, "rb") as f: - assert job.audio_file.read() == f.read() + assert upload.import_reference == "test" - assert job.source == "file://" + path - m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) + mocked_process.assert_called_once_with(upload_id=upload.pk) -@pytest.mark.skip("XXX : wip") def test_import_files_skip_if_path_already_imported(factories, mocker): - user = factories["users.User"](username="me") + library = factories["music.Library"](actor__local=True) path = os.path.join(DATA_DIR, "dummy_file.ogg") - factories["music.Upload"](source="file://{}".format(path)) - - call_command("import_files", path, username="me", async=False, interactive=False) - assert user.imports.count() == 0 + # existing one with same source + factories["music.Upload"]( + library=library, import_status="finished", source="file://{}".format(path) + ) -@pytest.mark.skip("Refactoring in progress") -def test_import_files_works_with_utf8_file_name(factories, mocker): - m = mocker.patch("funkwhale_api.music.tasks.import_job_run") - user = factories["users.User"](username="me") - path = os.path.join(DATA_DIR, "utf8-éà ◌.ogg") - call_command("import_files", path, username="me", async=False, interactive=False) - batch = user.imports.latest("id") - job = batch.jobs.first() - m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) + call_command( + "import_files", str(library.uuid), path, async=False, interactive=False + ) + assert library.uploads.count() == 1 -@pytest.mark.skip("Refactoring in progress") def test_import_files_in_place(factories, mocker, settings): settings.MUSIC_DIRECTORY_PATH = DATA_DIR - m = mocker.patch("funkwhale_api.music.tasks.import_job_run") - user = factories["users.User"](username="me") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + library = factories["music.Library"](actor__local=True) path = os.path.join(DATA_DIR, "utf8-éà ◌.ogg") call_command( "import_files", + str(library.uuid), path, - username="me", - async=False, + async_=False, in_place=True, interactive=False, ) - batch = user.imports.latest("id") - job = batch.jobs.first() - assert bool(job.audio_file) is False - m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) + upload = library.uploads.last() + assert bool(upload.audio_file) is False + mocked_process.assert_called_once_with(upload_id=upload.pk) def test_storage_rename_utf_8_files(factories): diff --git a/demo/setup.sh b/demo/setup.sh index 39d7e3456..3f238ebe9 100755 --- a/demo/setup.sh +++ b/demo/setup.sh @@ -48,6 +48,8 @@ u.set_password("demo") u.subsonic_api_token = "demo" u.save() +library = actor.libraries.create(name='Demo library', privacy_level='everyone') + from funkwhale_api.common import preferences manager = preferences.global_preferences_registry.manager() @@ -61,7 +63,7 @@ paths = [ "$music_path/**/*.flac", ] print(paths) -call_command("import_files", *paths, username="demo", recursive=True, interactive=False) +call_command("import_files", str(library.uuid), *paths, username="demo", recursive=True, interactive=False) print('Creating some dummy data...') @@ -73,7 +75,7 @@ from funkwhale_api.favorites.factories import TrackFavorite as TrackFavoriteFact from funkwhale_api.users.factories import UserFactory from funkwhale_api.playlists.factories import PlaylistFactory -users = UserFactory.create_batch(size=15, privacy_level="everyone") +users = UserFactory.create_batch(size=15, privacy_level="everyone", with_actor=True) available_tracks = list(Track.objects.all()) available_albums = list(Album.objects.all()) -- GitLab