diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..62d9c567e9693cb9a5542c5456b71e79315e6af5
--- /dev/null
+++ b/api/funkwhale_api/common/serializers.py
@@ -0,0 +1,76 @@
+from rest_framework import serializers
+
+
+class ActionSerializer(serializers.Serializer):
+    """
+    A special serializer that can operate on a list of objects
+    and apply actions on it.
+    """
+
+    action = serializers.CharField(required=True)
+    objects = serializers.JSONField(required=True)
+    filters = serializers.DictField(required=False)
+    actions = None
+    filterset_class = None
+
+    def __init__(self, *args, **kwargs):
+        self.queryset = kwargs.pop('queryset')
+        if self.actions is None:
+            raise ValueError(
+                'You must declare a list of actions on '
+                'the serializer class')
+
+        for action in self.actions:
+            handler_name = 'handle_{}'.format(action)
+            assert hasattr(self, handler_name), (
+                '{} miss a {} method'.format(
+                    self.__class__.__name__, handler_name)
+            )
+        super().__init__(self, *args, **kwargs)
+
+    def validate_action(self, value):
+        if value not in self.actions:
+            raise serializers.ValidationError(
+                '{} is not a valid action. Pick one of {}.'.format(
+                    value, ', '.join(self.actions)
+                )
+            )
+        return value
+
+    def validate_objects(self, value):
+        qs = None
+        if value == 'all':
+            return self.queryset.all().order_by('id')
+        if type(value) in [list, tuple]:
+            return self.queryset.filter(pk__in=value).order_by('id')
+
+        raise serializers.ValidationError(
+            '{} is not a valid value for objects. You must provide either a '
+            'list of identifiers or the string "all".'.format(value))
+
+    def validate(self, data):
+        if self.filterset_class and 'filters' in data:
+            qs_filterset = self.filterset_class(
+                data['filters'], queryset=data['objects'])
+            try:
+                assert qs_filterset.form.is_valid()
+            except (AssertionError, TypeError):
+                raise serializers.ValidationError('Invalid filters')
+            data['objects'] = qs_filterset.qs
+
+        data['count'] = data['objects'].count()
+        if data['count'] < 1:
+            raise serializers.ValidationError(
+                'No object matching your request')
+        return data
+
+    def save(self):
+        handler_name = 'handle_{}'.format(self.validated_data['action'])
+        handler = getattr(self, handler_name)
+        result = handler(self.validated_data['objects'])
+        payload = {
+            'updated': self.validated_data['count'],
+            'action': self.validated_data['action'],
+            'result': result,
+        }
+        return payload
diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py
index 7a388ff1298fbab740342c5de1885157ea433a94..1d93f68b993397407ca7b3476c0d8be05d977c4b 100644
--- a/api/funkwhale_api/federation/filters.py
+++ b/api/funkwhale_api/federation/filters.py
@@ -24,7 +24,7 @@ class LibraryFilter(django_filters.FilterSet):
 
 class LibraryTrackFilter(django_filters.FilterSet):
     library = django_filters.CharFilter('library__uuid')
-    imported = django_filters.CharFilter(method='filter_imported')
+    status = django_filters.CharFilter(method='filter_status')
     q = fields.SearchFilter(search_fields=[
         'artist_name',
         'title',
@@ -32,11 +32,15 @@ class LibraryTrackFilter(django_filters.FilterSet):
         'library__actor__domain',
     ])
 
-    def filter_imported(self, queryset, field_name, value):
-        if value.lower() in ['true', '1', 'yes']:
-            queryset = queryset.filter(local_track_file__isnull=False)
-        elif value.lower() in ['false', '0', 'no']:
-            queryset = queryset.filter(local_track_file__isnull=True)
+    def filter_status(self, queryset, field_name, value):
+        if value == 'imported':
+            return queryset.filter(local_track_file__isnull=False)
+        elif value == 'not_imported':
+            return queryset.filter(
+                local_track_file__isnull=True
+            ).exclude(import_jobs__status='pending')
+        elif value == 'import_pending':
+            return queryset.filter(import_jobs__status='pending')
         return queryset
 
     class Meta:
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 51561e222837d2fab9fcf3473112227c68e740e2..6ffffaa9aa37396bda8acb5fe2bcfe6d281b8edf 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -10,8 +10,11 @@ from rest_framework import serializers
 from dynamic_preferences.registries import global_preferences_registry
 
 from funkwhale_api.common import utils as funkwhale_utils
-
+from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.music import models as music_models
+from funkwhale_api.music import tasks as music_tasks
 from . import activity
+from . import filters
 from . import models
 from . import utils
 
@@ -293,6 +296,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer):
 
 class APILibraryTrackSerializer(serializers.ModelSerializer):
     library = APILibrarySerializer()
+    status = serializers.SerializerMethodField()
 
     class Meta:
         model = models.LibraryTrack
@@ -311,8 +315,20 @@ class APILibraryTrackSerializer(serializers.ModelSerializer):
             'title',
             'library',
             'local_track_file',
+            'status',
         ]
 
+    def get_status(self, o):
+        try:
+            if o.local_track_file is not None:
+                return 'imported'
+        except music_models.TrackFile.DoesNotExist:
+            pass
+        for job in o.import_jobs.all():
+            if job.status == 'pending':
+                return 'import_pending'
+        return 'not_imported'
+
 
 class FollowSerializer(serializers.Serializer):
     id = serializers.URLField(max_length=500)
@@ -806,3 +822,29 @@ class CollectionSerializer(serializers.Serializer):
         if self.context.get('include_ap_context', True):
             d['@context'] = AP_CONTEXT
         return d
+
+
+class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
+    actions = ['import']
+    filterset_class = filters.LibraryTrackFilter
+
+    @transaction.atomic
+    def handle_import(self, objects):
+        batch = music_models.ImportBatch.objects.create(
+            source='federation',
+            submitted_by=self.context['submitted_by']
+        )
+        jobs = []
+        for lt in objects:
+            job = music_models.ImportJob(
+                batch=batch,
+                library_track=lt,
+                mbid=lt.mbid,
+                source=lt.url,
+            )
+            jobs.append(job)
+
+        music_models.ImportJob.objects.bulk_create(jobs)
+        music_tasks.import_batch_run.delay(import_batch_id=batch.pk)
+
+        return {'batch': {'id': batch.pk}}
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 06a2cd040cfc0d94fe51ab419bf6159c6e14f631..1350ec731ece68010cc907191d6412ad425fdce3 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -15,7 +15,7 @@ from rest_framework.serializers import ValidationError
 
 from funkwhale_api.common import preferences
 from funkwhale_api.common import utils as funkwhale_utils
-from funkwhale_api.music.models import TrackFile
+from funkwhale_api.music import models as music_models
 from funkwhale_api.users.permissions import HasUserPermission
 
 from . import activity
@@ -148,7 +148,9 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
     def list(self, request, *args, **kwargs):
         page = request.GET.get('page')
         library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
-        qs = TrackFile.objects.order_by('-creation_date').select_related(
+        qs = music_models.TrackFile.objects.order_by(
+            '-creation_date'
+        ).select_related(
             'track__artist',
             'track__album__artist'
         ).filter(library_track__isnull=True)
@@ -294,7 +296,7 @@ class LibraryTrackViewSet(
         'library__actor',
         'library__follow',
         'local_track_file',
-    )
+    ).prefetch_related('import_jobs')
     filter_class = filters.LibraryTrackFilter
     serializer_class = serializers.APILibraryTrackSerializer
     ordering_fields = (
@@ -307,3 +309,16 @@ class LibraryTrackViewSet(
         'fetched_date',
         'published_date',
     )
+
+    @list_route(methods=['post'])
+    def action(self, request, *args, **kwargs):
+        queryset = models.LibraryTrack.objects.filter(
+            local_track_file__isnull=True)
+        serializer = serializers.LibraryTrackActionSerializer(
+            request.data,
+            queryset=queryset,
+            context={'submitted_by': request.user}
+        )
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index c77983a404cf89e9e9f4779ecc3e19773136fda0..b72bb8c4a63cce3c9b23ed2a62082527a00455d4 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -250,28 +250,6 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
         return 'Audio'
 
 
-class SubmitFederationTracksSerializer(serializers.Serializer):
-    library_tracks = serializers.PrimaryKeyRelatedField(
-        many=True,
-        queryset=LibraryTrack.objects.filter(local_track_file__isnull=True),
-    )
-
-    @transaction.atomic
-    def save(self, **kwargs):
-        batch = models.ImportBatch.objects.create(
-            source='federation',
-            **kwargs
-        )
-        for lt in self.validated_data['library_tracks']:
-            models.ImportJob.objects.create(
-                batch=batch,
-                library_track=lt,
-                mbid=lt.mbid,
-                source=lt.url,
-            )
-        return batch
-
-
 class ImportJobRunSerializer(serializers.Serializer):
     jobs = serializers.PrimaryKeyRelatedField(
         many=True,
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index e5426904a7f2b16bca519f37c1e30a0a2d5d4489..993456c2701bbff50477eacea02ef3a56cdac4e1 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -173,6 +173,13 @@ def import_job_run(self, import_job, replace=False, use_acoustid=False):
         raise
 
 
+@celery.app.task(name='ImportBatch.run')
+@celery.require_instance(models.ImportBatch, 'import_batch')
+def import_batch_run(import_batch):
+    for job_id in import_batch.jobs.order_by('id').values_list('id', flat=True):
+        import_job_run.delay(import_job_id=job_id)
+
+
 @celery.app.task(name='Lyrics.fetch_content')
 @celery.require_instance(models.Lyrics, 'lyrics')
 def fetch_content(lyrics):
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 5e3a7a4c1784e38a6e35bee0033a45b3ec20734c..aa07ad52c1a86c0f5f51e7eb254fd28ae2d69807 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -449,22 +449,6 @@ class SubmitViewSet(viewsets.ViewSet):
             data, request, batch=None, import_request=import_request)
         return Response(import_data)
 
-    @list_route(methods=['post'])
-    @transaction.non_atomic_requests
-    def federation(self, request, *args, **kwargs):
-        serializer = serializers.SubmitFederationTracksSerializer(
-            data=request.data)
-        serializer.is_valid(raise_exception=True)
-        batch = serializer.save(submitted_by=request.user)
-        for job in batch.jobs.all():
-            funkwhale_utils.on_commit(
-                tasks.import_job_run.delay,
-                import_job_id=job.pk,
-                use_acoustid=False,
-            )
-
-        return Response({'id': batch.id}, status=201)
-
     @transaction.atomic
     def _import_album(self, data, request, batch=None, import_request=None):
         # we import the whole album here to prevent race conditions that occurs
diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..5636765562d61c6262f7f17b77c85c3b5b98b128
--- /dev/null
+++ b/api/tests/common/test_serializers.py
@@ -0,0 +1,100 @@
+import django_filters
+
+from funkwhale_api.common import serializers
+from funkwhale_api.users import models
+
+
+class TestActionFilterSet(django_filters.FilterSet):
+    class Meta:
+        model = models.User
+        fields = ['is_active']
+
+
+class TestSerializer(serializers.ActionSerializer):
+    actions = ['test']
+    filterset_class = TestActionFilterSet
+
+    def handle_test(self, objects):
+        return {'hello': 'world'}
+
+
+def test_action_serializer_validates_action():
+    data = {'objects': 'all', 'action': 'nope'}
+    serializer = TestSerializer(data, queryset=models.User.objects.none())
+
+    assert serializer.is_valid() is False
+    assert 'action' in serializer.errors
+
+
+def test_action_serializer_validates_objects():
+    data = {'objects': 'nope', 'action': 'test'}
+    serializer = TestSerializer(data, queryset=models.User.objects.none())
+
+    assert serializer.is_valid() is False
+    assert 'objects' in serializer.errors
+
+
+def test_action_serializers_objects_clean_ids(factories):
+    user1 = factories['users.User']()
+    user2 = factories['users.User']()
+
+    data = {'objects': [user1.pk], 'action': 'test'}
+    serializer = TestSerializer(data, queryset=models.User.objects.all())
+
+    assert serializer.is_valid() is True
+    assert list(serializer.validated_data['objects']) == [user1]
+
+
+def test_action_serializers_objects_clean_all(factories):
+    user1 = factories['users.User']()
+    user2 = factories['users.User']()
+
+    data = {'objects': 'all', 'action': 'test'}
+    serializer = TestSerializer(data, queryset=models.User.objects.all())
+
+    assert serializer.is_valid() is True
+    assert list(serializer.validated_data['objects']) == [user1, user2]
+
+
+def test_action_serializers_save(factories, mocker):
+    handler = mocker.spy(TestSerializer, 'handle_test')
+    user1 = factories['users.User']()
+    user2 = factories['users.User']()
+
+    data = {'objects': 'all', 'action': 'test'}
+    serializer = TestSerializer(data, queryset=models.User.objects.all())
+
+    assert serializer.is_valid() is True
+    result = serializer.save()
+    assert result == {
+        'updated': 2,
+        'action': 'test',
+        'result': {'hello': 'world'},
+    }
+    handler.assert_called_once()
+
+
+def test_action_serializers_filterset(factories):
+    user1 = factories['users.User'](is_active=False)
+    user2 = factories['users.User'](is_active=True)
+
+    data = {
+        'objects': 'all',
+        'action': 'test',
+        'filters': {'is_active': True},
+    }
+    serializer = TestSerializer(data, queryset=models.User.objects.all())
+
+    assert serializer.is_valid() is True
+    assert list(serializer.validated_data['objects']) == [user2]
+
+
+def test_action_serializers_validates_at_least_one_object():
+    data = {
+        'objects': 'all',
+        'action': 'test',
+    }
+    serializer = TestSerializer(data, queryset=models.User.objects.none())
+
+    assert serializer.is_valid() is False
+    assert 'non_field_errors' in serializer.errors
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index f298c61f5fbd36f9516e607d00e60649c4b9b281..fcf2ba1b673d876660aa8825e2f8495bd10a9d99 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -699,3 +699,26 @@ def test_api_library_create_serializer_save(factories, r_mock):
     assert library.tracks_count == 10
     assert library.actor == actor
     assert library.follow == follow
+
+
+def test_tapi_library_track_serializer_not_imported(factories):
+    lt = factories['federation.LibraryTrack']()
+    serializer = serializers.APILibraryTrackSerializer(lt)
+
+    assert serializer.get_status(lt) == 'not_imported'
+
+
+def test_tapi_library_track_serializer_imported(factories):
+    tf = factories['music.TrackFile'](federation=True)
+    lt = tf.library_track
+    serializer = serializers.APILibraryTrackSerializer(lt)
+
+    assert serializer.get_status(lt) == 'imported'
+
+
+def test_tapi_library_track_serializer_import_pending(factories):
+    job = factories['music.ImportJob'](federation=True, status='pending')
+    lt = job.library_track
+    serializer = serializers.APILibraryTrackSerializer(lt)
+
+    assert serializer.get_status(lt) == 'import_pending'
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 10237ed9fd76d156656238edbce11495dc08934b..04a419aed6f5fae696cbd3d94f02c8420d9e9021 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -418,3 +418,39 @@ def test_can_filter_pending_follows(factories, superuser_api_client):
 
     assert response.status_code == 200
     assert len(response.data['results']) == 0
+
+
+def test_library_track_action_import(
+        factories, superuser_api_client, mocker):
+    lt1 = factories['federation.LibraryTrack']()
+    lt2 = factories['federation.LibraryTrack'](library=lt1.library)
+    lt3 = factories['federation.LibraryTrack']()
+    lt4 = factories['federation.LibraryTrack'](library=lt3.library)
+    mocked_run = mocker.patch(
+        'funkwhale_api.music.tasks.import_batch_run.delay')
+
+    payload = {
+        'objects': 'all',
+        'action': 'import',
+        'filters': {
+            'library': lt1.library.uuid
+        }
+    }
+    url = reverse('api:v1:federation:library-tracks-action')
+    response = superuser_api_client.post(url, payload, format='json')
+    batch = superuser_api_client.user.imports.latest('id')
+    expected = {
+        'updated': 2,
+        'action': 'import',
+        'result': {
+            'batch': {'id': batch.pk}
+        }
+    }
+
+    imported_lts = [lt1, lt2]
+    assert response.status_code == 200
+    assert response.data == expected
+    assert batch.jobs.count() == 2
+    for i, job in enumerate(batch.jobs.all()):
+        assert job.library_track == imported_lts[i]
+    mocked_run.assert_called_once_with(import_batch_id=batch.pk)
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index 26cb9453efacca6137f412bdd03c8df570f41a02..dfe649be0f91b460db912078d25ae825063af523 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -47,6 +47,15 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
     assert track_file.acoustid_track_id is None
 
 
+def test_import_batch_run(factories, mocker):
+    job = factories['music.ImportJob']()
+    mocked_job_run = mocker.patch(
+        'funkwhale_api.music.tasks.import_job_run.delay')
+    tasks.import_batch_run(import_batch_id=job.batch.pk)
+
+    mocked_job_run.assert_called_once_with(import_job_id=job.pk)
+
+
 def test_import_job_can_run_with_file_and_acoustid(
         artists, albums, tracks, preferences, factories, mocker):
     preferences['providers_acoustid__api_key'] = 'test'
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 38366442f309a4b80eb25f7dfeda517b608ab778..9328ba329ce79b3cc05413e7edb611b6f6e39c7f 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -249,24 +249,6 @@ def test_serve_updates_access_date(factories, settings, api_client):
     assert track_file.accessed_date > now
 
 
-def test_can_create_import_from_federation_tracks(
-        factories, superuser_api_client, mocker):
-    lts = factories['federation.LibraryTrack'].create_batch(size=5)
-    mocker.patch('funkwhale_api.music.tasks.import_job_run')
-
-    payload = {
-        'library_tracks': [l.pk for l in lts]
-    }
-    url = reverse('api:v1:submit-federation')
-    response = superuser_api_client.post(url, payload)
-
-    assert response.status_code == 201
-    batch = superuser_api_client.user.imports.latest('id')
-    assert batch.jobs.count() == 5
-    for i, job in enumerate(batch.jobs.all()):
-        assert job.library_track == lts[i]
-
-
 def test_can_list_import_jobs(factories, superuser_api_client):
     job = factories['music.ImportJob']()
     url = reverse('api:v1:import-jobs-list')
diff --git a/changes/changelog.d/164.enhancement b/changes/changelog.d/164.enhancement
new file mode 100644
index 0000000000000000000000000000000000000000..ceea6c2b8e53fb858bb051e9b4efdd70bb774654
--- /dev/null
+++ b/changes/changelog.d/164.enhancement
@@ -0,0 +1,2 @@
+Can now import a whole remote library at once thanks to new Action Table
+component (#164)
diff --git a/changes/changelog.d/228.feature b/changes/changelog.d/228.feature
new file mode 100644
index 0000000000000000000000000000000000000000..548c1927ebd6d1deb5d22161ff6d9cadf50dc30f
--- /dev/null
+++ b/changes/changelog.d/228.feature
@@ -0,0 +1,3 @@
+New action table component for quick and efficient batch actions (#228)
+This is implemented on the federated tracks pages, but will be included
+in other pages as well depending on the feedback.
diff --git a/front/package.json b/front/package.json
index 8844e8bee53c05c4436ec6e8783ca405972162ae..3dec9c2571a25cf4e8592bb5da7a28e7699d636a 100644
--- a/front/package.json
+++ b/front/package.json
@@ -33,7 +33,7 @@
     "raven-js": "^3.22.3",
     "semantic-ui-css": "^2.2.10",
     "showdown": "^1.8.6",
-    "vue": "^2.3.3",
+    "vue": "^2.5.16",
     "vue-lazyload": "^1.1.4",
     "vue-masonry": "^0.10.16",
     "vue-router": "^2.3.1",
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..718e57b19b61672913d0de6145ec7447c69ac0cf
--- /dev/null
+++ b/front/src/components/common/ActionTable.vue
@@ -0,0 +1,215 @@
+<template>
+  <table class="ui compact very basic single line unstackable table">
+    <thead>
+      <tr v-if="actions.length > 0">
+        <th colspan="1000">
+          <div class="ui small form">
+            <div class="ui inline fields">
+              <div class="field">
+                <label>{{ $t('Actions') }}</label>
+                <select class="ui dropdown" v-model="currentActionName">
+                  <option v-for="action in actions" :value="action.name">
+                    {{ action.label }}
+                  </option>
+                </select>
+              </div>
+              <div class="field">
+                <div
+                  v-if="!selectAll"
+                  @click="launchAction"
+                  :disabled="checked.length === 0"
+                  :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
+                  {{ $t('Go') }}</div>
+                <dangerous-button
+                  v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
+                  confirm-color="green"
+                  color=""
+                  @confirm="launchAction">
+                  {{ $t('Go') }}
+                  <p slot="modal-header">{{ $t('Do you want to launch action "{% action %}" on {% total %} elements?', {action: currentActionName, total: objectsData.count}) }}
+                  <p slot="modal-content">
+                    {{ $t('This may affect a lot of elements, please double check this is really what you want.')}}
+                  </p>
+                  <p slot="modal-confirm">{{ $t('Launch') }}</p>
+                </dangerous-button>
+              </div>
+              <div class="count field">
+                <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
+                <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
+                <template v-if="checkable.length === checked.length">
+                  <a @click="selectAll = true" v-if="!selectAll">
+                    {{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
+                  </a>
+                  <a @click="selectAll = false" v-else>
+                    {{ $t('Select only current page') }}
+                  </a>
+                </template>
+              </div>
+            </div>
+            <div v-if="actionErrors.length > 0" class="ui negative message">
+              <div class="header">{{ $t('Error while applying action') }}</div>
+              <ul class="list">
+                <li v-for="error in actionErrors">{{ error }}</li>
+              </ul>
+            </div>
+            <div v-if="actionResult" class="ui positive message">
+              <p>{{ $t('Action {% action %} was launched successfully on {% count %} objects.', {action: actionResult.action, count: actionResult.updated}) }}</p>
+              <slot name="action-success-footer" :result="actionResult">
+              </slot>
+            </div>
+          </div>
+        </th>
+      </tr>
+      <tr>
+        <th>
+          <div class="ui checkbox">
+            <input
+              type="checkbox"
+              @change="toggleCheckAll"
+              :disabled="checkable.length === 0"
+              :checked="checkable.length > 0 && checked.length === checkable.length"><label>&nbsp;</label>
+          </div>
+        </th>
+        <slot name="header-cells"></slot>
+      </tr>
+    </thead>
+    <tbody v-if="objectsData.count > 0">
+      <tr v-for="(obj, index) in objectsData.results">
+        <td class="collapsing">
+          <input
+            type="checkbox"
+            :disabled="checkable.indexOf(obj.id) === -1"
+            @click="toggleCheck($event, obj.id, index)"
+            :checked="checked.indexOf(obj.id) > -1"><label>&nbsp;</label>
+          </div>
+        </td>
+        <slot name="row-cells" :obj="obj"></slot>
+      </tr>
+    </tbody>
+  </table>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: {
+    actionUrl: {type: String, required: true},
+    objectsData: {type: Object, required: true},
+    actions: {type: Array, required: true, default: () => { return [] }},
+    filters: {type: Object, required: false, default: () => { return {} }}
+  },
+  components: {},
+  data () {
+    let d = {
+      checked: [],
+      actionLoading: false,
+      actionResult: null,
+      actionErrors: [],
+      currentActionName: null,
+      selectAll: false,
+      lastCheckedIndex: -1
+    }
+    if (this.actions.length > 0) {
+      d.currentActionName = this.actions[0].name
+    }
+    return d
+  },
+  methods: {
+    toggleCheckAll () {
+      this.lastCheckedIndex = -1
+      if (this.checked.length === this.checkable.length) {
+        // we uncheck
+        this.checked = []
+      } else {
+        this.checked = this.checkable.map(i => { return i })
+      }
+    },
+    toggleCheck (event, id, index) {
+      let self = this
+      let affectedIds = [id]
+      let newValue = null
+      if (this.checked.indexOf(id) > -1) {
+        // we uncheck
+        this.selectAll = false
+        newValue = false
+      } else {
+        newValue = true
+      }
+      if (event.shiftKey && this.lastCheckedIndex > -1) {
+        // we also add inbetween ids to the list of affected ids
+        let idxs = [index, this.lastCheckedIndex]
+        idxs.sort((a, b) => a - b)
+        let objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
+        affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
+      }
+      affectedIds.forEach((i) => {
+        let checked = self.checked.indexOf(i) > -1
+        if (newValue && !checked && self.checkable.indexOf(i) > -1) {
+          return self.checked.push(i)
+        }
+        if (!newValue && checked) {
+          self.checked.splice(self.checked.indexOf(i), 1)
+        }
+      })
+      this.lastCheckedIndex = index
+    },
+    launchAction () {
+      let self = this
+      self.actionLoading = true
+      self.result = null
+      let payload = {
+        action: this.currentActionName,
+        filters: this.filters
+      }
+      if (this.selectAll) {
+        payload.objects = 'all'
+      } else {
+        payload.objects = this.checked
+      }
+      axios.post(this.actionUrl, payload).then((response) => {
+        self.actionResult = response.data
+        self.actionLoading = false
+        self.$emit('action-launched', response.data)
+      }, error => {
+        self.actionLoading = false
+        self.actionErrors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    currentAction () {
+      let self = this
+      return this.actions.filter((a) => {
+        return a.name === self.currentActionName
+      })[0]
+    },
+    checkable () {
+      let objs = this.objectsData.results
+      let filter = this.currentAction.filterCheckable
+      if (filter) {
+        objs = objs.filter((o) => {
+          return filter(o)
+        })
+      }
+      return objs.map((o) => { return o.id })
+    }
+  },
+  watch: {
+    objectsData: {
+      handler () {
+        this.checked = []
+        this.selectAll = false
+      },
+      deep: true
+    }
+  }
+}
+</script>
+<style scoped>
+.count.field {
+  font-weight: normal;
+}
+.ui.form .inline.fields {
+  margin: 0;
+}
+</style>
diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue
index 690291d5b1d08abaa60f8576fce71b25a73f1047..52fcdca6136528bea7cb7b36125074654e62800e 100644
--- a/front/src/components/common/DangerousButton.vue
+++ b/front/src/components/common/DangerousButton.vue
@@ -13,7 +13,7 @@
       </div>
       <div class="actions">
         <div class="ui cancel button"><i18next path="Cancel"/></div>
-        <div :class="['ui', 'confirm', color, 'button']" @click="confirm">
+        <div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
           <slot name="modal-confirm"><i18next path="Confirm"/></slot>
         </div>
       </div>
@@ -28,7 +28,8 @@ export default {
   props: {
     action: {type: Function, required: false},
     disabled: {type: Boolean, default: false},
-    color: {type: String, default: 'red'}
+    color: {type: String, default: 'red'},
+    confirmColor: {type: String, default: null, required: false}
   },
   components: {
     Modal
@@ -38,6 +39,14 @@ export default {
       showModal: false
     }
   },
+  computed: {
+    confirmButtonColor () {
+      if (this.confirmColor) {
+        return this.confirmColor
+      }
+      return this.color
+    }
+  },
   methods: {
     confirm () {
       this.showModal = false
diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue
index d8ee48bf2b8e93f0ab9c22494ecee2883f5e859f..43b52c835bb84ec3434f5a8fca110e70ecd24ff7 100644
--- a/front/src/components/federation/LibraryTrackTable.vue
+++ b/front/src/components/federation/LibraryTrackTable.vue
@@ -10,95 +10,77 @@
           <label>{{ $t('Import status') }}</label>
           <select class="ui dropdown" v-model="importedFilter">
             <option :value="null">{{ $t('Any') }}</option>
-            <option :value="true">{{ $t('Imported') }}</option>
-            <option :value="false">{{ $t('Not imported') }}</option>
+            <option :value="'imported'">{{ $t('Imported') }}</option>
+            <option :value="'not_imported'">{{ $t('Not imported') }}</option>
+            <option :value="'import_pending'">{{ $t('Import pending') }}</option>
           </select>
         </div>
       </div>
     </div>
-    <table v-if="result" class="ui compact very basic single line unstackable table">
-      <thead>
-        <tr>
-          <th>
-            <div class="ui checkbox">
-              <input
-                type="checkbox"
-                @change="toggleCheckAll"
-                :checked="result.results.length === checked.length"><label>&nbsp;</label>
-            </div>
-          </th>
-          <i18next tag="th" path="Title"/>
-          <i18next tag="th" path="Artist"/>
-          <i18next tag="th" path="Album"/>
-          <i18next tag="th" path="Published date"/>
-          <i18next tag="th" v-if="showLibrary" path="Library"/>
-        </tr>
-      </thead>
-      <tbody>
-        <tr v-for="track in result.results">
-          <td class="collapsing">
-            <div v-if="!track.local_track_file" class="ui checkbox">
-              <input
-                type="checkbox"
-                @change="toggleCheck(track.id)"
-                :checked="checked.indexOf(track.id) > -1"><label>&nbsp;</label>
-            </div>
-            <div v-else class="ui label">
-              <i18next path="In library"/>
-            </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :objects-data="result"
+        :actions="actions"
+        :action-url="'federation/library-tracks/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th>{{ $t('Status') }}</th>
+          <th>{{ $t('Title') }}</th>
+          <th>{{ $t('Artist') }}</th>
+          <th>{{ $t('Album') }}</th>
+          <th>{{ $t('Published date') }}</th>
+          <th v-if="showLibrary">{{ $t('Library') }}</th>
+        </template>
+        <template slot="action-success-footer" slot-scope="scope">
+          <router-link
+            v-if="scope.result.action === 'import'"
+            :to="{name: 'library.import.batches.detail', params: {id: scope.result.result.batch.id }}">
+            {{ $t('Import #{% id %} launched', {id: scope.result.result.batch.id}) }}
+          </router-link>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <td>
+            <span v-if="scope.obj.status === 'imported'" class="ui basic green label">{{ $t('In library') }}</span>
+            <span v-else-if="scope.obj.status === 'import_pending'" class="ui basic yellow label">{{ $t('Import pending') }}</span>
+            <span v-else class="ui basic label">{{ $t('Not imported') }}</span>
           </td>
           <td>
-            <span :title="track.title">{{ track.title|truncate(30) }}</span>
+            <span :title="scope.obj.title">{{ scope.obj.title|truncate(30) }}</span>
           </td>
           <td>
-            <span :title="track.artist_name">{{ track.artist_name|truncate(30) }}</span>
+            <span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span>
           </td>
           <td>
-            <span :title="track.album_title">{{ track.album_title|truncate(20) }}</span>
+            <span :title="scope.obj.album_title">{{ scope.obj.album_title|truncate(20) }}</span>
           </td>
           <td>
-            <human-date :date="track.published_date"></human-date>
+            <human-date :date="scope.obj.published_date"></human-date>
           </td>
           <td v-if="showLibrary">
-            {{ track.library.actor.domain }}
+            {{ scope.obj.library.actor.domain }}
           </td>
-        </tr>
-      </tbody>
-      <tfoot class="full-width">
-        <tr>
-          <th>
-            <pagination
-            v-if="result && result.results.length > 0"
-            @page-changed="selectPage"
-            :compact="true"
-            :current="page"
-            :paginate-by="paginateBy"
-            :total="result.count"
-            ></pagination>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.results.length > 0"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
 
-          </th>
-          <th v-if="result && result.results.length > 0">
-            {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
-          <th>
-            <button
-              @click="launchImport"
-              :disabled="checked.length === 0 || isImporting"
-              :class="['ui', 'green', {loading: isImporting}, 'button']">
-              {{ $t('Import {%count%} tracks', {'count': checked.length}) }}
-            </button>
-            <router-link
-              v-if="importBatch"
-              :to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
-              {{ $t('Import #{% id %} launched', {id: importBatch.id}) }}
-            </router-link>
-          </th>
-          <th></th>
-          <th></th>
-          <th></th>
-          <th v-if="showLibrary"></th>
-        </tr>
-      </tfoot>
-    </table>
+      <span v-if="result && result.results.length > 0">
+        {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
+      </span>
+    </div>
   </div>
 </template>
 
@@ -107,6 +89,7 @@ import axios from 'axios'
 import _ from 'lodash'
 
 import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
 
 export default {
   props: {
@@ -114,7 +97,8 @@ export default {
     showLibrary: {type: Boolean, default: false}
   },
   components: {
-    Pagination
+    Pagination,
+    ActionTable
   },
   data () {
     return {
@@ -123,9 +107,6 @@ export default {
       page: 1,
       paginateBy: 25,
       search: '',
-      checked: {},
-      isImporting: false,
-      importBatch: null,
       importedFilter: null
     }
   },
@@ -140,7 +121,7 @@ export default {
         'q': this.search
       }, this.filters)
       if (this.importedFilter !== null) {
-        params.imported = this.importedFilter
+        params.status = this.importedFilter
       }
       let self = this
       self.isLoading = true
@@ -153,53 +134,41 @@ export default {
         self.errors = error.backendErrors
       })
     },
-    launchImport () {
-      let self = this
-      self.isImporting = true
-      let payload = {
-        library_tracks: this.checked
-      }
-      axios.post('/submit/federation/', payload).then((response) => {
-        self.importBatch = response.data
-        self.isImporting = false
-        self.fetchData()
-      }, error => {
-        self.isImporting = false
-        self.errors = error.backendErrors
-      })
-    },
-    toggleCheckAll () {
-      if (this.checked.length === this.result.results.length) {
-        // we uncheck
-        this.checked = []
-      } else {
-        this.checked = this.result.results.filter(t => {
-          return t.local_track_file === null
-        }).map(t => { return t.id })
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    actionFilters () {
+      var currentFilters = {
+        q: this.search
       }
-    },
-    toggleCheck (id) {
-      if (this.checked.indexOf(id) > -1) {
-        // we uncheck
-        this.checked.splice(this.checked.indexOf(id), 1)
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
       } else {
-        this.checked.push(id)
+        return currentFilters
       }
     },
-    selectPage: function (page) {
-      this.page = page
+    actions () {
+      return [
+        {
+          name: 'import',
+          label: this.$t('Import'),
+          filterCheckable: (obj) => { return obj.status === 'not_imported' }
+        }
+      ]
     }
   },
   watch: {
     search (newValue) {
-      if (newValue.length > 0) {
-        this.fetchData()
-      }
+      this.page = 1
+      this.fetchData()
     },
     page () {
       this.fetchData()
     },
     importedFilter () {
+      this.page = 1
       this.fetchData()
     }
   }