diff --git a/.env.example b/.env.example
index ca3f31a26c36feec34061ef188689df38d8e01bb..77e5ebb870a463b62c18725478452299ca8140c6 100644
--- a/.env.example
+++ b/.env.example
@@ -15,8 +15,8 @@
 #--------------------------------------------------------------------
 app.baseURL="https://YOUR_DOMAIN_NAME/"
 app.mediaBaseURL="https://YOUR_MEDIA_DOMAIN_NAME/"
-app.adminGateway="cp-admin"
-app.authGateway="cp-auth"
+admin.gateway="cp-admin"
+auth.gateway="cp-auth"
 
 #--------------------------------------------------------------------
 # Database configuration
diff --git a/app/Config/ActivityPub.php b/app/Config/ActivityPub.php
deleted file mode 100644
index fe8337d9b87418bee50993212b210539e9b30340..0000000000000000000000000000000000000000
--- a/app/Config/ActivityPub.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Config;
-
-use ActivityPub\Config\ActivityPub as ActivityPubBase;
-use App\Libraries\NoteObject;
-
-class ActivityPub extends ActivityPubBase
-{
-    /**
-     * --------------------------------------------------------------------
-     * ActivityPub Objects
-     * --------------------------------------------------------------------
-     */
-    public string $noteObject = NoteObject::class;
-
-    /**
-     * --------------------------------------------------------------------
-     * Default avatar and cover images
-     * --------------------------------------------------------------------
-     */
-    public string $defaultAvatarImagePath = 'media/castopod-avatar-default_thumbnail.jpg';
-
-    public string $defaultAvatarImageMimetype = 'image/jpeg';
-
-    public string $defaultCoverImagePath = 'media/castopod-cover-default.jpg';
-
-    public string $defaultCoverImageMimetype = 'image/jpeg';
-}
diff --git a/app/Config/App.php b/app/Config/App.php
index 4eb43e327c96ea74459370c279650088a704e79b..df445db0ed909e63448d7fe30e379ef8b4f4083d 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -427,28 +427,4 @@ class App extends BaseConfig
      * Defines the root folder for media files storage
      */
     public string $mediaRoot = 'media';
-
-    /**
-     * --------------------------------------------------------------------------
-     * Admin gateway
-     * --------------------------------------------------------------------------
-     * Defines a base route for all admin pages
-     */
-    public string $adminGateway = 'cp-admin';
-
-    /**
-     * --------------------------------------------------------------------------
-     * Auth gateway
-     * --------------------------------------------------------------------------
-     * Defines a base route for all authentication related pages
-     */
-    public string $authGateway = 'cp-auth';
-
-    /**
-     * --------------------------------------------------------------------------
-     * Install gateway
-     * --------------------------------------------------------------------------
-     * Defines a base route for instance installation
-     */
-    public string $installGateway = 'cp-install';
 }
diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php
index 9436638053425efc6f1bf2a919fc19c9a3148547..2bc6ca84789295fc832da3a833657a618b3353e5 100644
--- a/app/Config/Autoload.php
+++ b/app/Config/Autoload.php
@@ -43,6 +43,12 @@ class Autoload extends AutoloadConfig
      */
     public $psr4 = [
         APP_NAMESPACE => APPPATH,
+        'Modules' => ROOTPATH . 'modules',
+        'Modules\Admin' => ROOTPATH . 'modules/Admin',
+        'Modules\Auth' => ROOTPATH . 'modules/Auth',
+        'Modules\Analytics' => ROOTPATH . 'modules/Analytics',
+        'Modules\Install' => ROOTPATH . 'modules/Install',
+        'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse',
         'Config' => APPPATH . 'Config',
         'ActivityPub' => APPPATH . 'Libraries/ActivityPub',
         'Analytics' => APPPATH . 'Libraries/Analytics',
diff --git a/app/Config/Events.php b/app/Config/Events.php
index 59bfed7dad0034127b6d1e1fee828959bff73bf1..5a5a5155e0861a10b7dce5ab37bc126c9e72fb18 100644
--- a/app/Config/Events.php
+++ b/app/Config/Events.php
@@ -6,9 +6,9 @@ namespace Config;
 
 use App\Entities\Actor;
 use App\Entities\Post;
-use App\Entities\User;
 use CodeIgniter\Events\Events;
 use CodeIgniter\Exceptions\FrameworkException;
+use Modules\Auth\Entities\User;
 
 /*
  * --------------------------------------------------------------------
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index 9a5b8a2e8521ddcb6decfe9addf045a166a28ba5..397c496c3a29a91dbf8607d49386d50cbfc75b55 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -4,12 +4,12 @@ declare(strict_types=1);
 
 namespace Config;
 
-use ActivityPub\Filters\ActivityPubFilter;
-use App\Filters\PermissionFilter;
 use CodeIgniter\Config\BaseConfig;
 use CodeIgniter\Filters\CSRF;
 use CodeIgniter\Filters\DebugToolbar;
 use CodeIgniter\Filters\Honeypot;
+use Modules\Auth\Filters\PermissionFilter;
+use Modules\Fediverse\Filters\ActivityPubFilter;
 use Myth\Auth\Filters\LoginFilter;
 use Myth\Auth\Filters\RoleFilter;
 
@@ -70,7 +70,7 @@ class Filters extends BaseConfig
 
         $this->filters = [
             'login' => [
-                'before' => [config('App')->adminGateway . '*'],
+                'before' => [config('Admin') ->gateway . '*', config('Analytics') ->gateway . '*'],
             ],
         ];
     }
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 27c5ab80abefb505d1cc3aa494feafedfb22ac21..74d1a06acfda1728887a62e9066748831575b606 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -54,675 +54,20 @@ $routes->get('/', 'HomeController::index', [
     'as' => 'home',
 ]);
 
-// Install Wizard route
-$routes->group(config('App')->installGateway, function ($routes): void {
-    $routes->get('/', 'InstallController', [
-        'as' => 'install',
-    ]);
-    $routes->post('instance-config', 'InstallController::attemptInstanceConfig', [
-        'as' => 'instance-config',
-    ]);
-    $routes->post('database-config', 'InstallController::attemptDatabaseConfig', [
-        'as' => 'database-config',
-    ]);
-    $routes->post('cache-config', 'InstallController::attemptCacheConfig', [
-        'as' => 'cache-config',
-    ]);
-    $routes->post(
-        'create-superadmin',
-        'InstallController::attemptCreateSuperAdmin',
-        [
-            'as' => 'create-superadmin',
-        ],
-    );
-});
-
 $routes->get('.well-known/platforms', 'Platform');
 
-// Admin area
-$routes->group(
-    config('App')
-        ->adminGateway,
-    [
-        'namespace' => 'App\Controllers\Admin',
-    ],
-    function ($routes): void {
-        $routes->get('/', 'HomeController', [
-            'as' => 'admin',
-        ]);
-
-        $routes->group('persons', function ($routes): void {
-            $routes->get('/', 'PersonController', [
-                'as' => 'person-list',
-                'filter' => 'permission:person-list',
-            ]);
-            $routes->get('new', 'PersonController::create', [
-                'as' => 'person-create',
-                'filter' => 'permission:person-create',
-            ]);
-            $routes->post('new', 'PersonController::attemptCreate', [
-                'filter' => 'permission:person-create',
-            ]);
-            $routes->group('(:num)', function ($routes): void {
-                $routes->get('/', 'PersonController::view/$1', [
-                    'as' => 'person-view',
-                    'filter' => 'permission:person-view',
-                ]);
-                $routes->get('edit', 'PersonController::edit/$1', [
-                    'as' => 'person-edit',
-                    'filter' => 'permission:person-edit',
-                ]);
-                $routes->post('edit', 'PersonController::attemptEdit/$1', [
-                    'filter' => 'permission:person-edit',
-                ]);
-                $routes->add('delete', 'PersonController::delete/$1', [
-                    'as' => 'person-delete',
-                    'filter' => 'permission:person-delete',
-                ]);
-            });
-        });
-
-        // Podcasts
-        $routes->group('podcasts', function ($routes): void {
-            $routes->get('/', 'PodcastController::list', [
-                'as' => 'podcast-list',
-            ]);
-            $routes->get('new', 'PodcastController::create', [
-                'as' => 'podcast-create',
-                'filter' => 'permission:podcasts-create',
-            ]);
-            $routes->post('new', 'PodcastController::attemptCreate', [
-                'filter' => 'permission:podcasts-create',
-            ]);
-            $routes->get('import', 'PodcastImportController', [
-                'as' => 'podcast-import',
-                'filter' => 'permission:podcasts-import',
-            ]);
-            $routes->post('import', 'PodcastImportController::attemptImport', [
-                'filter' => 'permission:podcasts-import',
-            ]);
-
-            // Podcast
-            // Use ids in admin area to help permission and group lookups
-            $routes->group('(:num)', function ($routes): void {
-                $routes->get('/', 'PodcastController::view/$1', [
-                    'as' => 'podcast-view',
-                    'filter' => 'permission:podcasts-view,podcast-view',
-                ]);
-                $routes->get('edit', 'PodcastController::edit/$1', [
-                    'as' => 'podcast-edit',
-                    'filter' => 'permission:podcast-edit',
-                ]);
-                $routes->post('edit', 'PodcastController::attemptEdit/$1', [
-                    'filter' => 'permission:podcast-edit',
-                ]);
-                $routes->get('delete', 'PodcastController::delete/$1', [
-                    'as' => 'podcast-delete',
-                    'filter' => 'permission:podcasts-delete',
-                ]);
-
-                $routes->group('persons', function ($routes): void {
-                    $routes->get('/', 'PodcastPersonController/$1', [
-                        'as' => 'podcast-person-manage',
-                        'filter' => 'permission:podcast-edit',
-                    ]);
-                    $routes->post(
-                        '/',
-                        'PodcastPersonController::attemptAdd/$1',
-                        [
-                            'filter' => 'permission:podcast-edit',
-                        ],
-                    );
-
-                    $routes->get(
-                        '(:num)/remove',
-                        'PodcastPersonController::remove/$1/$2',
-                        [
-                            'as' => 'podcast-person-remove',
-                            'filter' => 'permission:podcast-edit',
-                        ],
-                    );
-                });
-
-                $routes->group('analytics', function ($routes): void {
-                    $routes->get('/', 'PodcastController::viewAnalytics/$1', [
-                        'as' => 'podcast-analytics',
-                        'filter' => 'permission:podcasts-view,podcast-view',
-                    ]);
-                    $routes->get(
-                        'webpages',
-                        'PodcastController::viewAnalyticsWebpages/$1',
-                        [
-                            'as' => 'podcast-analytics-webpages',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                    $routes->get(
-                        'locations',
-                        'PodcastController::viewAnalyticsLocations/$1',
-                        [
-                            'as' => 'podcast-analytics-locations',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                    $routes->get(
-                        'unique-listeners',
-                        'PodcastController::viewAnalyticsUniqueListeners/$1',
-                        [
-                            'as' => 'podcast-analytics-unique-listeners',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                    $routes->get(
-                        'listening-time',
-                        'PodcastController::viewAnalyticsListeningTime/$1',
-                        [
-                            'as' => 'podcast-analytics-listening-time',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                    $routes->get(
-                        'time-periods',
-                        'PodcastController::viewAnalyticsTimePeriods/$1',
-                        [
-                            'as' => 'podcast-analytics-time-periods',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                    $routes->get(
-                        'players',
-                        'PodcastController::viewAnalyticsPlayers/$1',
-                        [
-                            'as' => 'podcast-analytics-players',
-                            'filter' => 'permission:podcasts-view,podcast-view',
-                        ],
-                    );
-                });
-
-                // Podcast episodes
-                $routes->group('episodes', function ($routes): void {
-                    $routes->get('/', 'EpisodeController::list/$1', [
-                        'as' => 'episode-list',
-                        'filter' =>
-                            'permission:episodes-list,podcast_episodes-list',
-                    ]);
-                    $routes->get('new', 'EpisodeController::create/$1', [
-                        'as' => 'episode-create',
-                        'filter' => 'permission:podcast_episodes-create',
-                    ]);
-                    $routes->post(
-                        'new',
-                        'EpisodeController::attemptCreate/$1',
-                        [
-                            'filter' => 'permission:podcast_episodes-create',
-                        ],
-                    );
-
-                    // Episode
-                    $routes->group('(:num)', function ($routes): void {
-                        $routes->get('/', 'EpisodeController::view/$1/$2', [
-                            'as' => 'episode-view',
-                            'filter' =>
-                                'permission:episodes-view,podcast_episodes-view',
-                        ]);
-                        $routes->get('edit', 'EpisodeController::edit/$1/$2', [
-                            'as' => 'episode-edit',
-                            'filter' => 'permission:podcast_episodes-edit',
-                        ]);
-                        $routes->post(
-                            'edit',
-                            'EpisodeController::attemptEdit/$1/$2',
-                            [
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->get(
-                            'publish',
-                            'EpisodeController::publish/$1/$2',
-                            [
-                                'as' => 'episode-publish',
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->post(
-                            'publish',
-                            'EpisodeController::attemptPublish/$1/$2',
-                            [
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->get(
-                            'publish-edit',
-                            'EpisodeController::publishEdit/$1/$2',
-                            [
-                                'as' => 'episode-publish_edit',
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->post(
-                            'publish-edit',
-                            'EpisodeController::attemptPublishEdit/$1/$2',
-                            [
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->get(
-                            'publish-cancel',
-                            'EpisodeController::publishCancel/$1/$2',
-                            [
-                                'as' => 'episode-publish-cancel',
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->get(
-                            'unpublish',
-                            'EpisodeController::unpublish/$1/$2',
-                            [
-                                'as' => 'episode-unpublish',
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->post(
-                            'unpublish',
-                            'EpisodeController::attemptUnpublish/$1/$2',
-                            [
-                                'filter' =>
-                                    'permission:podcast-manage_publications',
-                            ],
-                        );
-                        $routes->get(
-                            'delete',
-                            'EpisodeController::delete/$1/$2',
-                            [
-                                'as' => 'episode-delete',
-                                'filter' =>
-                                    'permission:podcast_episodes-delete',
-                            ],
-                        );
-                        $routes->get(
-                            'transcript-delete',
-                            'EpisodeController::transcriptDelete/$1/$2',
-                            [
-                                'as' => 'transcript-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->get(
-                            'chapters-delete',
-                            'EpisodeController::chaptersDelete/$1/$2',
-                            [
-                                'as' => 'chapters-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->get(
-                            'soundbites',
-                            'EpisodeController::soundbitesEdit/$1/$2',
-                            [
-                                'as' => 'soundbites-edit',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->post(
-                            'soundbites',
-                            'EpisodeController::soundbitesAttemptEdit/$1/$2',
-                            [
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->get(
-                            'soundbites/(:num)/delete',
-                            'EpisodeController::soundbiteDelete/$1/$2/$3',
-                            [
-                                'as' => 'soundbite-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-                        $routes->get(
-                            'embeddable-player',
-                            'EpisodeController::embeddablePlayer/$1/$2',
-                            [
-                                'as' => 'embeddable-player-add',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ],
-                        );
-
-                        $routes->group('persons', function ($routes): void {
-                            $routes->get('/', 'EpisodePersonController/$1/$2', [
-                                'as' => 'episode-person-manage',
-                                'filter' => 'permission:podcast_episodes-edit',
-                            ]);
-                            $routes->post(
-                                '/',
-                                'EpisodePersonController::attemptAdd/$1/$2',
-                                [
-                                    'filter' =>
-                                        'permission:podcast_episodes-edit',
-                                ],
-                            );
-                            $routes->get(
-                                '(:num)/remove',
-                                'EpisodePersonController::remove/$1/$2/$3',
-                                [
-                                    'as' => 'episode-person-remove',
-                                    'filter' =>
-                                        'permission:podcast_episodes-edit',
-                                ],
-                            );
-                        });
-
-                        $routes->group('comments', function ($routes): void {
-                            $routes->post(
-                                'new',
-                                'EpisodeController::attemptCommentCreate/$1/$2',
-                                [
-                                    'as' => 'comment-attempt-create',
-                                    'filter' => 'permission:podcast-manage_publications',
-                                ]
-                            );
-                            $routes->post(
-                                '(:uuid)/reply',
-                                'EpisodeController::attemptCommentReply/$1/$2/$3',
-                                [
-                                    'as' => 'comment-attempt-reply',
-                                    'filter' => 'permission:podcast-manage_publications',
-                                ]
-                            );
-                            $routes->post(
-                                'delete',
-                                'EpisodeController::attemptCommentDelete/$1/$2',
-                                [
-                                    'as' => 'comment-attempt-delete',
-                                    'filter' => 'permission:podcast-manage_publications',
-                                ]
-                            );
-                        });
-                    });
-                });
-
-                // Podcast contributors
-                $routes->group('contributors', function ($routes): void {
-                    $routes->get('/', 'ContributorController::list/$1', [
-                        'as' => 'contributor-list',
-                        'filter' =>
-                            'permission:podcasts-view,podcast-manage_contributors',
-                    ]);
-                    $routes->get('add', 'ContributorController::add/$1', [
-                        'as' => 'contributor-add',
-                        'filter' => 'permission:podcast-manage_contributors',
-                    ]);
-                    $routes->post(
-                        'add',
-                        'ContributorController::attemptAdd/$1',
-                        [
-                            'filter' =>
-                                'permission:podcast-manage_contributors',
-                        ],
-                    );
-
-                    // Contributor
-                    $routes->group('(:num)', function ($routes): void {
-                        $routes->get('/', 'ContributorController::view/$1/$2', [
-                            'as' => 'contributor-view',
-                            'filter' =>
-                                'permission:podcast-manage_contributors',
-                        ]);
-                        $routes->get(
-                            'edit',
-                            'ContributorController::edit/$1/$2',
-                            [
-                                'as' => 'contributor-edit',
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                        $routes->post(
-                            'edit',
-                            'ContributorController::attemptEdit/$1/$2',
-                            [
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                        $routes->get(
-                            'remove',
-                            'ContributorController::remove/$1/$2',
-                            [
-                                'as' => 'contributor-remove',
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                    });
-                });
-
-                $routes->group('platforms', function ($routes): void {
-                    $routes->get(
-                        '/',
-                        'PodcastPlatformController::platforms/$1/podcasting',
-                        [
-                            'as' => 'platforms-podcasting',
-                            'filter' => 'permission:podcast-manage_platforms',
-                        ],
-                    );
-                    $routes->get(
-                        'social',
-                        'PodcastPlatformController::platforms/$1/social',
-                        [
-                            'as' => 'platforms-social',
-                            'filter' => 'permission:podcast-manage_platforms',
-                        ],
-                    );
-                    $routes->get(
-                        'funding',
-                        'PodcastPlatformController::platforms/$1/funding',
-                        [
-                            'as' => 'platforms-funding',
-                            'filter' => 'permission:podcast-manage_platforms',
-                        ],
-                    );
-                    $routes->post(
-                        'save/(:platformType)',
-                        'PodcastPlatformController::attemptPlatformsUpdate/$1/$2',
-                        [
-                            'as' => 'platforms-save',
-                            'filter' => 'permission:podcast-manage_platforms',
-                        ],
-                    );
-                    $routes->get(
-                        '(:slug)/podcast-platform-remove',
-                        'PodcastPlatformController::removePodcastPlatform/$1/$2',
-                        [
-                            'as' => 'podcast-platform-remove',
-                            'filter' => 'permission:podcast-manage_platforms',
-                        ],
-                    );
-                });
-            });
-        });
-
-        // Instance wide Fediverse config
-        $routes->group('fediverse', function ($routes): void {
-            $routes->get('/', 'FediverseController::dashboard', [
-                'as' => 'fediverse-dashboard',
-            ]);
-            $routes->get(
-                'blocked-actors',
-                'FediverseController::blockedActors',
-                [
-                    'as' => 'fediverse-blocked-actors',
-                    'filter' => 'permission:fediverse-block_actors',
-                ],
-            );
-            $routes->get(
-                'blocked-domains',
-                'FediverseController::blockedDomains',
-                [
-                    'as' => 'fediverse-blocked-domains',
-                    'filter' => 'permission:fediverse-block_domains',
-                ],
-            );
-        });
-
-        // Pages
-        $routes->group('pages', function ($routes): void {
-            $routes->get('/', 'PageController::list', [
-                'as' => 'page-list',
-            ]);
-            $routes->get('new', 'PageController::create', [
-                'as' => 'page-create',
-                'filter' => 'permission:pages-manage',
-            ]);
-            $routes->post('new', 'PageController::attemptCreate', [
-                'filter' => 'permission:pages-manage',
-            ]);
-
-            $routes->group('(:num)', function ($routes): void {
-                $routes->get('/', 'PageController::view/$1', [
-                    'as' => 'page-view',
-                ]);
-                $routes->get('edit', 'PageController::edit/$1', [
-                    'as' => 'page-edit',
-                    'filter' => 'permission:pages-manage',
-                ]);
-                $routes->post('edit', 'PageController::attemptEdit/$1', [
-                    'filter' => 'permission:pages-manage',
-                ]);
-
-                $routes->get('delete', 'PageController::delete/$1', [
-                    'as' => 'page-delete',
-                    'filter' => 'permission:pages-manage',
-                ]);
-            });
-        });
-
-        // Users
-        $routes->group('users', function ($routes): void {
-            $routes->get('/', 'UserController::list', [
-                'as' => 'user-list',
-                'filter' => 'permission:users-list',
-            ]);
-            $routes->get('new', 'UserController::create', [
-                'as' => 'user-create',
-                'filter' => 'permission:users-create',
-            ]);
-            $routes->post('new', 'UserController::attemptCreate', [
-                'filter' => 'permission:users-create',
-            ]);
-
-            // User
-            $routes->group('(:num)', function ($routes): void {
-                $routes->get('/', 'UserController::view/$1', [
-                    'as' => 'user-view',
-                    'filter' => 'permission:users-view',
-                ]);
-                $routes->get('edit', 'UserController::edit/$1', [
-                    'as' => 'user-edit',
-                    'filter' => 'permission:users-manage_authorizations',
-                ]);
-                $routes->post('edit', 'UserController::attemptEdit/$1', [
-                    'filter' => 'permission:users-manage_authorizations',
-                ]);
-                $routes->get('ban', 'UserController::ban/$1', [
-                    'as' => 'user-ban',
-                    'filter' => 'permission:users-manage_bans',
-                ]);
-                $routes->get('unban', 'UserController::unBan/$1', [
-                    'as' => 'user-unban',
-                    'filter' => 'permission:users-manage_bans',
-                ]);
-                $routes->get(
-                    'force-pass-reset',
-                    'UserController::forcePassReset/$1',
-                    [
-                        'as' => 'user-force_pass_reset',
-                        'filter' => 'permission:users-force_pass_reset',
-                    ],
-                );
-                $routes->get('delete', 'UserController::delete/$1', [
-                    'as' => 'user-delete',
-                    'filter' => 'permission:users-delete',
-                ]);
-            });
-        });
-
-        // My account
-        $routes->group('my-account', function ($routes): void {
-            $routes->get('/', 'MyAccountController', [
-                'as' => 'my-account',
-            ]);
-            $routes->get(
-                'change-password',
-                'MyAccountController::changePassword/$1',
-                [
-                    'as' => 'change-password',
-                ],
-            );
-            $routes->post('change-password', 'MyAccountController::attemptChange/$1');
-        });
-    },
-);
-
-/**
- * Overwriting Myth:auth routes file
- */
-$routes->group(config('App')->authGateway, function ($routes): void {
-    // Login/out
-    $routes->get('login', 'AuthController::login', [
-        'as' => 'login',
-    ]);
-    $routes->post('login', 'AuthController::attemptLogin');
-    $routes->get('logout', 'AuthController::logout', [
-        'as' => 'logout',
-    ]);
-
-    // Registration
-    $routes->get('register', 'AuthController::register', [
-        'as' => 'register',
-    ]);
-    $routes->post('register', 'AuthController::attemptRegister');
-
-    // Activation
-    $routes->get('activate-account', 'AuthController::activateAccount', [
-        'as' => 'activate-account',
-    ]);
-    $routes->get(
-        'resend-activate-account',
-        'AuthController::resendActivateAccount',
-        [
-            'as' => 'resend-activate-account',
-        ],
-    );
-
-    // Forgot/Resets
-    $routes->get('forgot', 'AuthController::forgotPassword', [
-        'as' => 'forgot',
-    ]);
-    $routes->post('forgot', 'AuthController::attemptForgot');
-    $routes->get('reset-password', 'AuthController::resetPassword', [
-        'as' => 'reset-password',
-    ]);
-    $routes->post('reset-password', 'AuthController::attemptReset');
-});
-
 // Podcast's Public routes
 $routes->group('@(:podcastHandle)', function ($routes): void {
     $routes->get('/', 'PodcastController::activity/$1', [
         'as' => 'podcast-activity',
     ]);
-    // override default ActivityPub Library's actor route
+    // override default Fediverse Library's actor route
     $routes->options('/', 'ActivityPubController::preflight');
     $routes->get('/', 'PodcastController::activity/$1', [
         'as' => 'actor',
         'alternate-content' => [
             'application/activity+json' => [
-                'namespace' => 'ActivityPub\Controllers',
+                'namespace' => 'Modules\Fediverse\Controllers',
                 'controller-method' => 'ActorController/$1',
             ],
             'application/podcast-activity+json' => [
@@ -730,7 +75,7 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
                 'controller-method' => 'PodcastController::podcastActor/$1',
             ],
             'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
-                'namespace' => 'ActivityPub\Controllers',
+                'namespace' => 'Modules\Fediverse\Controllers',
                 'controller-method' => 'ActorController/$1',
             ],
         ],
@@ -839,13 +184,8 @@ $routes->get('/pages/(:slug)', 'PageController/$1', [
     'as' => 'page',
 ]);
 
-// interacting as an actor
-$routes->post('interact-as-actor', 'AuthController::attemptInteractAsActor', [
-    'as' => 'interact-as-actor',
-]);
-
 /**
- * Overwriting ActivityPub routes file
+ * Overwriting Fediverse routes file
  */
 $routes->group('@(:podcastHandle)', function ($routes): void {
     $routes->post('posts/new', 'PostController::attemptCreate/$1', [
@@ -860,11 +200,11 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
             'as' => 'post',
             'alternate-content' => [
                 'application/activity+json' => [
-                    'namespace' => 'ActivityPub\Controllers',
+                    'namespace' => 'Modules\Fediverse\Controllers',
                     'controller-method' => 'PostController/$2',
                 ],
                 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
-                    'namespace' => 'ActivityPub\Controllers',
+                    'namespace' => 'Modules\Fediverse\Controllers',
                     'controller-method' => 'PostController/$2',
                 ],
             ],
@@ -874,11 +214,11 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
             'as' => 'post-replies',
             'alternate-content' => [
                 'application/activity+json' => [
-                    'namespace' => 'ActivityPub\Controllers',
+                    'namespace' => 'Modules\Fediverse\Controllers',
                     'controller-method' => 'PostController::replies/$2',
                 ],
                 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
-                    'namespace' => 'ActivityPub\Controllers',
+                    'namespace' => 'Modules\Fediverse\Controllers',
                     'controller-method' => 'PostController::replies/$2',
                 ],
             ],
diff --git a/app/Config/Services.php b/app/Config/Services.php
index 51578dd824276f77575bfcdfc5464ff3ada06c24..0dc63ead8e2cc4c107da34c45503abf949cee85f 100644
--- a/app/Config/Services.php
+++ b/app/Config/Services.php
@@ -4,20 +4,14 @@ declare(strict_types=1);
 
 namespace Config;
 
-use App\Authorization\FlatAuthorization;
-use App\Authorization\GroupModel;
-use App\Authorization\PermissionModel;
 use App\Libraries\Breadcrumb;
 use App\Libraries\Negotiate;
 use App\Libraries\Router;
 use App\Libraries\Vite;
-use App\Models\UserModel;
 use CodeIgniter\Config\BaseService;
 use CodeIgniter\HTTP\Request;
 use CodeIgniter\HTTP\RequestInterface;
-use CodeIgniter\Model;
 use CodeIgniter\Router\RouteCollectionInterface;
-use Myth\Auth\Models\LoginModel;
 
 /**
  * Services Configuration file.
@@ -69,70 +63,6 @@ class Services extends BaseService
         return new Negotiate($request);
     }
 
-    /**
-     * @return mixed
-     */
-    public static function authentication(
-        string $lib = 'local',
-        Model $userModel = null,
-        Model $loginModel = null,
-        bool $getShared = true
-    ) {
-        if ($getShared) {
-            return self::getSharedInstance('authentication', $lib, $userModel, $loginModel);
-        }
-
-        // config() checks first in app/Config
-        $config = config('Auth');
-
-        $class = $config->authenticationLibs[$lib];
-
-        $instance = new $class($config);
-
-        if ($userModel === null) {
-            $userModel = new UserModel();
-        }
-
-        if ($loginModel === null) {
-            $loginModel = new LoginModel();
-        }
-
-        return $instance->setUserModel($userModel)
-            ->setLoginModel($loginModel);
-    }
-
-    /**
-     * @return mixed|$this
-     */
-    public static function authorization(
-        Model $groupModel = null,
-        Model $permissionModel = null,
-        Model $userModel = null,
-        bool $getShared = true
-    ) {
-        if ($getShared) {
-            return self::getSharedInstance('authorization', $groupModel, $permissionModel, $userModel);
-        }
-
-        if ($groupModel === null) {
-            $groupModel = new GroupModel();
-        }
-
-        if ($permissionModel === null) {
-            $permissionModel = new PermissionModel();
-        }
-
-        /* @phpstan-ignore-next-line */
-        $instance = new FlatAuthorization($groupModel, $permissionModel);
-
-        if ($userModel === null) {
-            $userModel = new UserModel();
-        }
-
-        /* @phpstan-ignore-next-line */
-        return $instance->setUserModel($userModel);
-    }
-
     public static function breadcrumb(bool $getShared = true): Breadcrumb
     {
         if ($getShared) {
diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php
index fb9c2bc680cef7c927727b2ce479a0d4261c5656..75e8d8ffdaede4e8369b7b4eb20051cc8e48bb35 100644
--- a/app/Controllers/ActorController.php
+++ b/app/Controllers/ActorController.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
-use ActivityPub\Controllers\ActorController as ActivityPubActorController;
-use Analytics\AnalyticsTrait;
+use Modules\Analytics\AnalyticsTrait;
+use Modules\Fediverse\Controllers\ActorController as ActivityPubActorController;
 
 class ActorController extends ActivityPubActorController
 {
diff --git a/app/Controllers/EpisodeCommentController.php b/app/Controllers/EpisodeCommentController.php
index 12ccf9d60517d276646f8982f42cd0a4ae4655d9..964cb0f16e409c2be742ea9e09c5932e1dc0c647 100644
--- a/app/Controllers/EpisodeCommentController.php
+++ b/app/Controllers/EpisodeCommentController.php
@@ -10,11 +10,6 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
-use ActivityPub\Entities\Actor;
-use ActivityPub\Objects\OrderedCollectionObject;
-use ActivityPub\Objects\OrderedCollectionPage;
-use Analytics\AnalyticsTrait;
-use App\Controllers\Admin\BaseController;
 use App\Entities\Episode;
 use App\Entities\EpisodeComment;
 use App\Entities\Podcast;
@@ -25,6 +20,10 @@ use App\Models\PodcastModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\Response;
+use Modules\Analytics\AnalyticsTrait;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Objects\OrderedCollectionObject;
+use Modules\Fediverse\Objects\OrderedCollectionPage;
 
 class EpisodeCommentController extends BaseController
 {
diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php
index c721fb6fb78a3ec153de40e005c68ab73a4f970e..5e584cfa59e8d9f42e98efcca2ae78410ebb9af9 100644
--- a/app/Controllers/EpisodeController.php
+++ b/app/Controllers/EpisodeController.php
@@ -10,9 +10,6 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
-use ActivityPub\Objects\OrderedCollectionObject;
-use ActivityPub\Objects\OrderedCollectionPage;
-use Analytics\AnalyticsTrait;
 use App\Entities\Episode;
 use App\Entities\Podcast;
 use App\Libraries\NoteObject;
@@ -24,6 +21,9 @@ use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\Response;
 use CodeIgniter\HTTP\ResponseInterface;
 use Config\Services;
+use Modules\Analytics\AnalyticsTrait;
+use Modules\Fediverse\Objects\OrderedCollectionObject;
+use Modules\Fediverse\Objects\OrderedCollectionPage;
 use SimpleXMLElement;
 
 class EpisodeController extends BaseController
diff --git a/app/Controllers/PodcastController.php b/app/Controllers/PodcastController.php
index a21f117ec351057beaaeaf6c90844615f5cee216..47892deba2b902628c152384469bee8d85a12789 100644
--- a/app/Controllers/PodcastController.php
+++ b/app/Controllers/PodcastController.php
@@ -10,9 +10,6 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
-use ActivityPub\Objects\OrderedCollectionObject;
-use ActivityPub\Objects\OrderedCollectionPage;
-use Analytics\AnalyticsTrait;
 use App\Entities\Podcast;
 use App\Libraries\PodcastActor;
 use App\Libraries\PodcastEpisode;
@@ -21,6 +18,9 @@ use App\Models\PodcastModel;
 use App\Models\PostModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\Response;
+use Modules\Analytics\AnalyticsTrait;
+use Modules\Fediverse\Objects\OrderedCollectionObject;
+use Modules\Fediverse\Objects\OrderedCollectionPage;
 
 class PodcastController extends BaseController
 {
diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php
index c3761da63be47e1078f52a0542174b784a962e05..1cdafa464a25c3db0a9a13cef920c455cf0423d6 100644
--- a/app/Controllers/PostController.php
+++ b/app/Controllers/PostController.php
@@ -10,9 +10,6 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
-use ActivityPub\Controllers\PostController as ActivityPubPostController;
-use ActivityPub\Entities\Post as ActivityPubPost;
-use Analytics\AnalyticsTrait;
 use App\Entities\Actor;
 use App\Entities\Podcast;
 use App\Entities\Post as CastopodPost;
@@ -23,6 +20,9 @@ use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\URI;
 use CodeIgniter\I18n\Time;
+use Modules\Analytics\AnalyticsTrait;
+use Modules\Fediverse\Controllers\PostController as ActivityPubPostController;
+use Modules\Fediverse\Entities\Post as ActivityPubPost;
 
 class PostController extends ActivityPubPostController
 {
diff --git a/app/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php b/app/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php
index 70180803878fdd378c5b2219a905f16d1fb2700f..660f6578a603a9fdbfe0d82df4d446c46a124019 100644
--- a/app/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php
+++ b/app/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace App\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Database/Migrations/2021-08-12-160000_add_likes.php b/app/Database/Migrations/2021-08-12-160000_add_likes.php
index a32dc75ef831e4698590d4dc03f0364b120c0725..116fae4556f72c1f07afe60c1a404d2a5f100a55 100644
--- a/app/Database/Migrations/2021-08-12-160000_add_likes.php
+++ b/app/Database/Migrations/2021-08-12-160000_add_likes.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace App\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Entities/Actor.php b/app/Entities/Actor.php
index 7696ad420c298542653b9b7d87a5259007ba01fe..dc256a56a8ec55d51a0aa9239cf53f2131ed0f5f 100644
--- a/app/Entities/Actor.php
+++ b/app/Entities/Actor.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
-use ActivityPub\Entities\Actor as ActivityPubActor;
 use App\Models\PodcastModel;
+use Modules\Fediverse\Entities\Actor as ActivityPubActor;
 use RuntimeException;
 
 /**
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 80517a82cd3bd145936249ff96d33483b297e864..4861fa371a3237ccfc6de91735ac842bff77322c 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -19,6 +19,7 @@ use App\Models\UserModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\I18n\Time;
 use League\CommonMark\CommonMarkConverter;
+use Modules\Auth\Entities\User;
 use RuntimeException;
 
 /**
diff --git a/app/Entities/Post.php b/app/Entities/Post.php
index a76d86333601e24c1fcae64eb82ab1aefa402ae1..228c7efe2b1f46a63859da02ad35d68068fa526b 100644
--- a/app/Entities/Post.php
+++ b/app/Entities/Post.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
-use ActivityPub\Entities\Post as ActivityPubPost;
 use App\Models\EpisodeModel;
+use Modules\Fediverse\Entities\Post as ActivityPubPost;
 use RuntimeException;
 
 /**
diff --git a/app/Helpers/auth_helper.php b/app/Helpers/auth_helper.php
index 31842fbbba4b113d27f8f54e3298068477ffdfc7..7346267353fd45722fed7064ba2da498488026d9 100644
--- a/app/Helpers/auth_helper.php
+++ b/app/Helpers/auth_helper.php
@@ -8,10 +8,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-use ActivityPub\Entities\Actor;
-use App\Entities\User;
 use CodeIgniter\Database\Exceptions\DataException;
-use Config\Services;
+use Modules\Auth\Entities\User;
+use Modules\Fediverse\Entities\Actor;
 
 if (! function_exists('user')) {
     /**
@@ -19,7 +18,7 @@ if (! function_exists('user')) {
      */
     function user(): ?User
     {
-        $authenticate = Services::authentication();
+        $authenticate = service('authentication');
         $authenticate->check();
         return $authenticate->user();
     }
@@ -31,7 +30,7 @@ if (! function_exists('set_interact_as_actor')) {
      */
     function set_interact_as_actor(int $actorId): void
     {
-        $authenticate = Services::authentication();
+        $authenticate = service('authentication');
         $authenticate->check();
 
         $session = session();
@@ -56,7 +55,7 @@ if (! function_exists('interact_as_actor_id')) {
      */
     function interact_as_actor_id(): int
     {
-        $authenticate = Services::authentication();
+        $authenticate = service('authentication');
         $authenticate->check();
 
         $session = session();
@@ -70,7 +69,7 @@ if (! function_exists('interact_as_actor')) {
      */
     function interact_as_actor(): Actor | false
     {
-        $authenticate = Services::authentication();
+        $authenticate = service('authentication');
         $authenticate->check();
 
         $session = session();
diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php
index b3a7e3bb21e026a0e0c84d42cea3f6244f816e50..a0fa5e44e707699d601d6f7d285d176e8e6b1034 100644
--- a/app/Language/en/Breadcrumb.php
+++ b/app/Language/en/Breadcrumb.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 return [
     'label' => 'breadcrumb',
-    config('App')
-        ->adminGateway => 'Home',
+    config('Admin')
+        ->gateway => 'Home',
     'podcasts' => 'podcasts',
     'episodes' => 'episodes',
     'contributors' => 'contributors',
diff --git a/app/Language/fr/Breadcrumb.php b/app/Language/fr/Breadcrumb.php
index 81c1e17c197eab2e39789f15bf21f88dfa869217..08d8d888ac87bf879415fe1537662a59ca4b96ac 100644
--- a/app/Language/fr/Breadcrumb.php
+++ b/app/Language/fr/Breadcrumb.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 return [
     'label' => 'Fil d’Ariane',
-    config('App')
-        ->adminGateway => 'Accueil',
+    config('Admin')
+        ->gateway => 'Accueil',
     'podcasts' => 'podcasts',
     'episodes' => 'épisodes',
     'contributors' => 'contributeurs',
diff --git a/app/Libraries/Analytics/Config/Analytics.php b/app/Libraries/Analytics/Config/Analytics.php
deleted file mode 100644
index 08f7a901e186fad351ede0cf3bc9d3c4945a8fe3..0000000000000000000000000000000000000000
--- a/app/Libraries/Analytics/Config/Analytics.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Analytics\Config;
-
-use CodeIgniter\Config\BaseConfig;
-
-class Analytics extends BaseConfig
-{
-    /**
-     * Gateway to analytic routes. By default, all analytics routes will be under `/analytics` path
-     */
-    public string $gateway = 'analytics';
-
-    /**
-     * --------------------------------------------------------------------
-     * Route filters options
-     * --------------------------------------------------------------------
-     * @var array<string, string>
-     */
-    public array $routeFilters = [
-        'analytics-full-data' => '',
-        'analytics-data' => '',
-        'analytics-filtered-data' => '',
-    ];
-
-    /**
-     * get the full audio file
-     *
-     * @param string|string[] $audioFilePath
-     */
-    public function getAudioFileUrl(string | array $audioFilePath): string
-    {
-        return base_url($audioFilePath);
-    }
-}
diff --git a/app/Libraries/CommentObject.php b/app/Libraries/CommentObject.php
index 0cbd39629dae5c1da0f019b8d02505b0eede8629..7a4b7ce2d55a663868a1fa245f314593597eb0fb 100644
--- a/app/Libraries/CommentObject.php
+++ b/app/Libraries/CommentObject.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Libraries;
 
-use ActivityPub\Core\ObjectType;
 use App\Entities\EpisodeComment;
+use Modules\Fediverse\Core\ObjectType;
 
 class CommentObject extends ObjectType
 {
diff --git a/app/Libraries/NoteObject.php b/app/Libraries/NoteObject.php
index 7c28bd69b1161b8101757b9c943bbd5932e0a221..84af0c183fca63eb229ca66f999929c0097a7ef0 100644
--- a/app/Libraries/NoteObject.php
+++ b/app/Libraries/NoteObject.php
@@ -10,15 +10,15 @@ declare(strict_types=1);
 
 namespace App\Libraries;
 
-use ActivityPub\Objects\NoteObject as ActivityPubNoteObject;
 use App\Entities\Post;
+use Modules\Fediverse\Objects\NoteObject as ActivityPubNoteObject;
 
 class NoteObject extends ActivityPubNoteObject
 {
     /**
      * @param Post $post
      */
-    public function __construct(\ActivityPub\Entities\Post $post)
+    public function __construct($post)
     {
         parent::__construct($post);
 
diff --git a/app/Libraries/PodcastActor.php b/app/Libraries/PodcastActor.php
index 48b72feda23eb9646c3bcb028729f25a7ebd06b1..cfb984210090526c7bf541c3d8b411e63355e128 100644
--- a/app/Libraries/PodcastActor.php
+++ b/app/Libraries/PodcastActor.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Libraries;
 
-use ActivityPub\Objects\ActorObject;
 use App\Entities\Podcast;
+use Modules\Fediverse\Objects\ActorObject;
 
 class PodcastActor extends ActorObject
 {
diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php
index c12248e3ca913b004cc803d778cf3504535fab3f..b6c9f6e522034b79cb81b005a4939b203cf47310 100644
--- a/app/Libraries/PodcastEpisode.php
+++ b/app/Libraries/PodcastEpisode.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Libraries;
 
-use ActivityPub\Core\ObjectType;
 use App\Entities\Episode;
+use Modules\Fediverse\Core\ObjectType;
 
 class PodcastEpisode extends ObjectType
 {
diff --git a/app/Models/ActorModel.php b/app/Models/ActorModel.php
index 8191299ae49596958c60ff448c3f76e784e84939..333722df218e68789b67073fde5353bd2163c87c 100644
--- a/app/Models/ActorModel.php
+++ b/app/Models/ActorModel.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use ActivityPub\Models\ActorModel as ActivityPubActorModel;
 use App\Entities\Actor;
+use Modules\Fediverse\Models\ActorModel as ActivityPubActorModel;
 
 class ActorModel extends ActivityPubActorModel
 {
diff --git a/app/Models/EpisodeCommentModel.php b/app/Models/EpisodeCommentModel.php
index a5e4abf020a9b88720e3b06a7992e94b31369cff..c1f60dace8f474fab7ea22ed88d3d22aac8279f1 100644
--- a/app/Models/EpisodeCommentModel.php
+++ b/app/Models/EpisodeCommentModel.php
@@ -10,11 +10,11 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use ActivityPub\Activities\CreateActivity;
 use App\Entities\EpisodeComment;
 use App\Libraries\CommentObject;
 use CodeIgniter\Database\BaseBuilder;
 use Michalsn\Uuid\UuidModel;
+use Modules\Fediverse\Activities\CreateActivity;
 
 class EpisodeCommentModel extends UuidModel
 {
diff --git a/app/Models/LikeModel.php b/app/Models/LikeModel.php
index 01d49d1bd725474e1f055eb4f5110758ce1da6bd..23ea790fe9d6f7ccd183c179d05a577fbe8897f6 100644
--- a/app/Models/LikeModel.php
+++ b/app/Models/LikeModel.php
@@ -10,12 +10,12 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use ActivityPub\Activities\LikeActivity;
-use ActivityPub\Activities\UndoActivity;
-use ActivityPub\Entities\Actor;
 use App\Entities\EpisodeComment;
 use App\Entities\Like;
 use Michalsn\Uuid\UuidModel;
+use Modules\Fediverse\Activities\LikeActivity;
+use Modules\Fediverse\Activities\UndoActivity;
+use Modules\Fediverse\Entities\Actor;
 
 class LikeModel extends UuidModel
 {
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index d39d366aa0601a377d9523594b11e0631e5c31ae..8022db3a691776f277a51b46e268cb848526053a 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -385,7 +385,7 @@ class PodcastModel extends Model
 
             // delete all cache for podcast actor
             cache()
-                ->deleteMatching(config('ActivityPub') ->cachePrefix . "actor#{$podcast->actor_id}*");
+                ->deleteMatching(config('Fediverse') ->cachePrefix . "actor#{$podcast->actor_id}*");
 
             // delete model requests cache, includes feed / query / episode lists, etc.
             cache()
diff --git a/app/Models/PostModel.php b/app/Models/PostModel.php
index db01aee40a396601ceb9450652e2f1bd07b8dc76..201ad32741cb34730841cc19096a14d777287de7 100644
--- a/app/Models/PostModel.php
+++ b/app/Models/PostModel.php
@@ -10,8 +10,8 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use ActivityPub\Models\PostModel as ActivityPubPostModel;
 use App\Entities\Post;
+use Modules\Fediverse\Models\PostModel as ActivityPubPostModel;
 
 class PostModel extends ActivityPubPostModel
 {
diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php
index 63e90f983e8d8fae647d872c4be3b5676e653cf5..1da09221f54118c2e7debebf5528d2cc71a9bfa3 100644
--- a/app/Models/UserModel.php
+++ b/app/Models/UserModel.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use App\Entities\User;
+use Modules\Auth\Entities\User;
 use Myth\Auth\Models\UserModel as MythAuthUserModel;
 
 class UserModel extends MythAuthUserModel
diff --git a/docs/setup-development.md b/docs/setup-development.md
index 38adde22d4051011bc3d0945a49ffb5e2c2e76ee..e021a6a95d635d5d2f5dab76075d7df81a1ea4ed 100644
--- a/docs/setup-development.md
+++ b/docs/setup-development.md
@@ -51,8 +51,8 @@ to help you kickstart your contribution.
    app.baseURL="http://localhost:8080/"
    app.mediaBaseURL="http://localhost:8080/"
 
-   app.adminGateway="cp-admin"
-   app.authGateway="cp-auth"
+   admin.gateway="cp-admin"
+   auth.gateway="cp-auth"
 
    database.default.hostname="mariadb"
    database.default.database="castopod"
diff --git a/ecs.php b/ecs.php
index 1ef585e7341ba94a6a04bae1e29823783cdf178e..8d32e4f9a8fb0292fc91edb6261d54d6ac8dbe00 100644
--- a/ecs.php
+++ b/ecs.php
@@ -11,6 +11,7 @@ return static function (ContainerConfigurator $containerConfigurator): void {
     // alternative to CLI arguments, easier to maintain and extend
     $parameters->set(Option::PATHS, [
         __DIR__ . '/app',
+        __DIR__ . '/modules',
         __DIR__ . '/tests',
         __DIR__ . '/public',
     ]);
@@ -18,6 +19,7 @@ return static function (ContainerConfigurator $containerConfigurator): void {
     $parameters->set(Option::SKIP, [
         // TODO: restrict some rules for views?
         __DIR__ . '/app/Views/*',
+        __DIR__ . '/modules/**/Views/*',
 
         // skip specific generated files
         __DIR__ . '/app/Language/*/PersonsTaxonomy.php',
diff --git a/modules/Admin/Config/Admin.php b/modules/Admin/Config/Admin.php
new file mode 100644
index 0000000000000000000000000000000000000000..d71f39b738b2b2eb4728d897015bfa057ccadc5f
--- /dev/null
+++ b/modules/Admin/Config/Admin.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Admin\Config;
+
+use CodeIgniter\Config\BaseConfig;
+
+class Admin extends BaseConfig
+{
+    /**
+     * --------------------------------------------------------------------------
+     * Admin gateway
+     * --------------------------------------------------------------------------
+     * Defines a base route for all admin pages
+     */
+    public string $gateway = 'cp-admin';
+}
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
new file mode 100644
index 0000000000000000000000000000000000000000..f70dcd97eda20b59d38c7782b2376b0f30170572
--- /dev/null
+++ b/modules/Admin/Config/Routes.php
@@ -0,0 +1,597 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Admin\Config;
+
+$routes = service('routes');
+
+// Admin area routes
+$routes->group(
+    config('Admin')
+        ->gateway,
+    [
+        'namespace' => 'Modules\Admin\Controllers',
+    ],
+    function ($routes): void {
+        $routes->get('/', 'HomeController', [
+            'as' => 'admin',
+        ]);
+
+        $routes->group('persons', function ($routes): void {
+            $routes->get('/', 'PersonController', [
+                'as' => 'person-list',
+                'filter' => 'permission:person-list',
+            ]);
+            $routes->get('new', 'PersonController::create', [
+                'as' => 'person-create',
+                'filter' => 'permission:person-create',
+            ]);
+            $routes->post('new', 'PersonController::attemptCreate', [
+                'filter' => 'permission:person-create',
+            ]);
+            $routes->group('(:num)', function ($routes): void {
+                $routes->get('/', 'PersonController::view/$1', [
+                    'as' => 'person-view',
+                    'filter' => 'permission:person-view',
+                ]);
+                $routes->get('edit', 'PersonController::edit/$1', [
+                    'as' => 'person-edit',
+                    'filter' => 'permission:person-edit',
+                ]);
+                $routes->post('edit', 'PersonController::attemptEdit/$1', [
+                    'filter' => 'permission:person-edit',
+                ]);
+                $routes->add('delete', 'PersonController::delete/$1', [
+                    'as' => 'person-delete',
+                    'filter' => 'permission:person-delete',
+                ]);
+            });
+        });
+
+        // Podcasts
+        $routes->group('podcasts', function ($routes): void {
+            $routes->get('/', 'PodcastController::list', [
+                'as' => 'podcast-list',
+            ]);
+            $routes->get('new', 'PodcastController::create', [
+                'as' => 'podcast-create',
+                'filter' => 'permission:podcasts-create',
+            ]);
+            $routes->post('new', 'PodcastController::attemptCreate', [
+                'filter' => 'permission:podcasts-create',
+            ]);
+            $routes->get('import', 'PodcastImportController', [
+                'as' => 'podcast-import',
+                'filter' => 'permission:podcasts-import',
+            ]);
+            $routes->post('import', 'PodcastImportController::attemptImport', [
+                'filter' => 'permission:podcasts-import',
+            ]);
+
+            // Podcast
+            // Use ids in admin area to help permission and group lookups
+            $routes->group('(:num)', function ($routes): void {
+                $routes->get('/', 'PodcastController::view/$1', [
+                    'as' => 'podcast-view',
+                    'filter' => 'permission:podcasts-view,podcast-view',
+                ]);
+                $routes->get('edit', 'PodcastController::edit/$1', [
+                    'as' => 'podcast-edit',
+                    'filter' => 'permission:podcast-edit',
+                ]);
+                $routes->post('edit', 'PodcastController::attemptEdit/$1', [
+                    'filter' => 'permission:podcast-edit',
+                ]);
+                $routes->get('delete', 'PodcastController::delete/$1', [
+                    'as' => 'podcast-delete',
+                    'filter' => 'permission:podcasts-delete',
+                ]);
+
+                $routes->group('persons', function ($routes): void {
+                    $routes->get('/', 'PodcastPersonController/$1', [
+                        'as' => 'podcast-person-manage',
+                        'filter' => 'permission:podcast-edit',
+                    ]);
+                    $routes->post(
+                        '/',
+                        'PodcastPersonController::attemptAdd/$1',
+                        [
+                            'filter' => 'permission:podcast-edit',
+                        ],
+                    );
+
+                    $routes->get(
+                        '(:num)/remove',
+                        'PodcastPersonController::remove/$1/$2',
+                        [
+                            'as' => 'podcast-person-remove',
+                            'filter' => 'permission:podcast-edit',
+                        ],
+                    );
+                });
+
+                $routes->group('analytics', function ($routes): void {
+                    $routes->get('/', 'PodcastController::viewAnalytics/$1', [
+                        'as' => 'podcast-analytics',
+                        'filter' => 'permission:podcasts-view,podcast-view',
+                    ]);
+                    $routes->get(
+                        'webpages',
+                        'PodcastController::viewAnalyticsWebpages/$1',
+                        [
+                            'as' => 'podcast-analytics-webpages',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                    $routes->get(
+                        'locations',
+                        'PodcastController::viewAnalyticsLocations/$1',
+                        [
+                            'as' => 'podcast-analytics-locations',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                    $routes->get(
+                        'unique-listeners',
+                        'PodcastController::viewAnalyticsUniqueListeners/$1',
+                        [
+                            'as' => 'podcast-analytics-unique-listeners',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                    $routes->get(
+                        'listening-time',
+                        'PodcastController::viewAnalyticsListeningTime/$1',
+                        [
+                            'as' => 'podcast-analytics-listening-time',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                    $routes->get(
+                        'time-periods',
+                        'PodcastController::viewAnalyticsTimePeriods/$1',
+                        [
+                            'as' => 'podcast-analytics-time-periods',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                    $routes->get(
+                        'players',
+                        'PodcastController::viewAnalyticsPlayers/$1',
+                        [
+                            'as' => 'podcast-analytics-players',
+                            'filter' => 'permission:podcasts-view,podcast-view',
+                        ],
+                    );
+                });
+
+                // Podcast episodes
+                $routes->group('episodes', function ($routes): void {
+                    $routes->get('/', 'EpisodeController::list/$1', [
+                        'as' => 'episode-list',
+                        'filter' =>
+                            'permission:episodes-list,podcast_episodes-list',
+                    ]);
+                    $routes->get('new', 'EpisodeController::create/$1', [
+                        'as' => 'episode-create',
+                        'filter' => 'permission:podcast_episodes-create',
+                    ]);
+                    $routes->post(
+                        'new',
+                        'EpisodeController::attemptCreate/$1',
+                        [
+                            'filter' => 'permission:podcast_episodes-create',
+                        ],
+                    );
+
+                    // Episode
+                    $routes->group('(:num)', function ($routes): void {
+                        $routes->get('/', 'EpisodeController::view/$1/$2', [
+                            'as' => 'episode-view',
+                            'filter' =>
+                                'permission:episodes-view,podcast_episodes-view',
+                        ]);
+                        $routes->get('edit', 'EpisodeController::edit/$1/$2', [
+                            'as' => 'episode-edit',
+                            'filter' => 'permission:podcast_episodes-edit',
+                        ]);
+                        $routes->post(
+                            'edit',
+                            'EpisodeController::attemptEdit/$1/$2',
+                            [
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'publish',
+                            'EpisodeController::publish/$1/$2',
+                            [
+                                'as' => 'episode-publish',
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->post(
+                            'publish',
+                            'EpisodeController::attemptPublish/$1/$2',
+                            [
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->get(
+                            'publish-edit',
+                            'EpisodeController::publishEdit/$1/$2',
+                            [
+                                'as' => 'episode-publish_edit',
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->post(
+                            'publish-edit',
+                            'EpisodeController::attemptPublishEdit/$1/$2',
+                            [
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->get(
+                            'publish-cancel',
+                            'EpisodeController::publishCancel/$1/$2',
+                            [
+                                'as' => 'episode-publish-cancel',
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->get(
+                            'unpublish',
+                            'EpisodeController::unpublish/$1/$2',
+                            [
+                                'as' => 'episode-unpublish',
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->post(
+                            'unpublish',
+                            'EpisodeController::attemptUnpublish/$1/$2',
+                            [
+                                'filter' =>
+                                    'permission:podcast-manage_publications',
+                            ],
+                        );
+                        $routes->get(
+                            'delete',
+                            'EpisodeController::delete/$1/$2',
+                            [
+                                'as' => 'episode-delete',
+                                'filter' =>
+                                    'permission:podcast_episodes-delete',
+                            ],
+                        );
+                        $routes->get(
+                            'transcript-delete',
+                            'EpisodeController::transcriptDelete/$1/$2',
+                            [
+                                'as' => 'transcript-delete',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'chapters-delete',
+                            'EpisodeController::chaptersDelete/$1/$2',
+                            [
+                                'as' => 'chapters-delete',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'soundbites',
+                            'EpisodeController::soundbitesEdit/$1/$2',
+                            [
+                                'as' => 'soundbites-edit',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->post(
+                            'soundbites',
+                            'EpisodeController::soundbitesAttemptEdit/$1/$2',
+                            [
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'soundbites/(:num)/delete',
+                            'EpisodeController::soundbiteDelete/$1/$2/$3',
+                            [
+                                'as' => 'soundbite-delete',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'embeddable-player',
+                            'EpisodeController::embeddablePlayer/$1/$2',
+                            [
+                                'as' => 'embeddable-player-add',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+
+                        $routes->group('persons', function ($routes): void {
+                            $routes->get('/', 'EpisodePersonController/$1/$2', [
+                                'as' => 'episode-person-manage',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ]);
+                            $routes->post(
+                                '/',
+                                'EpisodePersonController::attemptAdd/$1/$2',
+                                [
+                                    'filter' =>
+                                        'permission:podcast_episodes-edit',
+                                ],
+                            );
+                            $routes->get(
+                                '(:num)/remove',
+                                'EpisodePersonController::remove/$1/$2/$3',
+                                [
+                                    'as' => 'episode-person-remove',
+                                    'filter' =>
+                                        'permission:podcast_episodes-edit',
+                                ],
+                            );
+                        });
+
+                        $routes->group('comments', function ($routes): void {
+                            $routes->post(
+                                'new',
+                                'EpisodeController::attemptCommentCreate/$1/$2',
+                                [
+                                    'as' => 'comment-attempt-create',
+                                    'filter' => 'permission:podcast-manage_publications',
+                                ]
+                            );
+                            $routes->post(
+                                '(:uuid)/reply',
+                                'EpisodeController::attemptCommentReply/$1/$2/$3',
+                                [
+                                    'as' => 'comment-attempt-reply',
+                                    'filter' => 'permission:podcast-manage_publications',
+                                ]
+                            );
+                            $routes->post(
+                                'delete',
+                                'EpisodeController::attemptCommentDelete/$1/$2',
+                                [
+                                    'as' => 'comment-attempt-delete',
+                                    'filter' => 'permission:podcast-manage_publications',
+                                ]
+                            );
+                        });
+                    });
+                });
+
+                // Podcast contributors
+                $routes->group('contributors', function ($routes): void {
+                    $routes->get('/', 'ContributorController::list/$1', [
+                        'as' => 'contributor-list',
+                        'filter' =>
+                            'permission:podcasts-view,podcast-manage_contributors',
+                    ]);
+                    $routes->get('add', 'ContributorController::add/$1', [
+                        'as' => 'contributor-add',
+                        'filter' => 'permission:podcast-manage_contributors',
+                    ]);
+                    $routes->post(
+                        'add',
+                        'ContributorController::attemptAdd/$1',
+                        [
+                            'filter' =>
+                                'permission:podcast-manage_contributors',
+                        ],
+                    );
+
+                    // Contributor
+                    $routes->group('(:num)', function ($routes): void {
+                        $routes->get('/', 'ContributorController::view/$1/$2', [
+                            'as' => 'contributor-view',
+                            'filter' =>
+                                'permission:podcast-manage_contributors',
+                        ]);
+                        $routes->get(
+                            'edit',
+                            'ContributorController::edit/$1/$2',
+                            [
+                                'as' => 'contributor-edit',
+                                'filter' =>
+                                    'permission:podcast-manage_contributors',
+                            ],
+                        );
+                        $routes->post(
+                            'edit',
+                            'ContributorController::attemptEdit/$1/$2',
+                            [
+                                'filter' =>
+                                    'permission:podcast-manage_contributors',
+                            ],
+                        );
+                        $routes->get(
+                            'remove',
+                            'ContributorController::remove/$1/$2',
+                            [
+                                'as' => 'contributor-remove',
+                                'filter' =>
+                                    'permission:podcast-manage_contributors',
+                            ],
+                        );
+                    });
+                });
+
+                $routes->group('platforms', function ($routes): void {
+                    $routes->get(
+                        '/',
+                        'PodcastPlatformController::platforms/$1/podcasting',
+                        [
+                            'as' => 'platforms-podcasting',
+                            'filter' => 'permission:podcast-manage_platforms',
+                        ],
+                    );
+                    $routes->get(
+                        'social',
+                        'PodcastPlatformController::platforms/$1/social',
+                        [
+                            'as' => 'platforms-social',
+                            'filter' => 'permission:podcast-manage_platforms',
+                        ],
+                    );
+                    $routes->get(
+                        'funding',
+                        'PodcastPlatformController::platforms/$1/funding',
+                        [
+                            'as' => 'platforms-funding',
+                            'filter' => 'permission:podcast-manage_platforms',
+                        ],
+                    );
+                    $routes->post(
+                        'save/(:platformType)',
+                        'PodcastPlatformController::attemptPlatformsUpdate/$1/$2',
+                        [
+                            'as' => 'platforms-save',
+                            'filter' => 'permission:podcast-manage_platforms',
+                        ],
+                    );
+                    $routes->get(
+                        '(:slug)/podcast-platform-remove',
+                        'PodcastPlatformController::removePodcastPlatform/$1/$2',
+                        [
+                            'as' => 'podcast-platform-remove',
+                            'filter' => 'permission:podcast-manage_platforms',
+                        ],
+                    );
+                });
+            });
+        });
+
+        // Instance wide Fediverse config
+        $routes->group('fediverse', function ($routes): void {
+            $routes->get('/', 'FediverseController::dashboard', [
+                'as' => 'fediverse-dashboard',
+            ]);
+            $routes->get(
+                'blocked-actors',
+                'FediverseController::blockedActors',
+                [
+                    'as' => 'fediverse-blocked-actors',
+                    'filter' => 'permission:fediverse-block_actors',
+                ],
+            );
+            $routes->get(
+                'blocked-domains',
+                'FediverseController::blockedDomains',
+                [
+                    'as' => 'fediverse-blocked-domains',
+                    'filter' => 'permission:fediverse-block_domains',
+                ],
+            );
+        });
+
+        // Pages
+        $routes->group('pages', function ($routes): void {
+            $routes->get('/', 'PageController::list', [
+                'as' => 'page-list',
+            ]);
+            $routes->get('new', 'PageController::create', [
+                'as' => 'page-create',
+                'filter' => 'permission:pages-manage',
+            ]);
+            $routes->post('new', 'PageController::attemptCreate', [
+                'filter' => 'permission:pages-manage',
+            ]);
+
+            $routes->group('(:num)', function ($routes): void {
+                $routes->get('/', 'PageController::view/$1', [
+                    'as' => 'page-view',
+                ]);
+                $routes->get('edit', 'PageController::edit/$1', [
+                    'as' => 'page-edit',
+                    'filter' => 'permission:pages-manage',
+                ]);
+                $routes->post('edit', 'PageController::attemptEdit/$1', [
+                    'filter' => 'permission:pages-manage',
+                ]);
+
+                $routes->get('delete', 'PageController::delete/$1', [
+                    'as' => 'page-delete',
+                    'filter' => 'permission:pages-manage',
+                ]);
+            });
+        });
+
+        // Users
+        $routes->group('users', function ($routes): void {
+            $routes->get('/', 'UserController::list', [
+                'as' => 'user-list',
+                'filter' => 'permission:users-list',
+            ]);
+            $routes->get('new', 'UserController::create', [
+                'as' => 'user-create',
+                'filter' => 'permission:users-create',
+            ]);
+            $routes->post('new', 'UserController::attemptCreate', [
+                'filter' => 'permission:users-create',
+            ]);
+
+            // User
+            $routes->group('(:num)', function ($routes): void {
+                $routes->get('/', 'UserController::view/$1', [
+                    'as' => 'user-view',
+                    'filter' => 'permission:users-view',
+                ]);
+                $routes->get('edit', 'UserController::edit/$1', [
+                    'as' => 'user-edit',
+                    'filter' => 'permission:users-manage_authorizations',
+                ]);
+                $routes->post('edit', 'UserController::attemptEdit/$1', [
+                    'filter' => 'permission:users-manage_authorizations',
+                ]);
+                $routes->get('ban', 'UserController::ban/$1', [
+                    'as' => 'user-ban',
+                    'filter' => 'permission:users-manage_bans',
+                ]);
+                $routes->get('unban', 'UserController::unBan/$1', [
+                    'as' => 'user-unban',
+                    'filter' => 'permission:users-manage_bans',
+                ]);
+                $routes->get(
+                    'force-pass-reset',
+                    'UserController::forcePassReset/$1',
+                    [
+                        'as' => 'user-force_pass_reset',
+                        'filter' => 'permission:users-force_pass_reset',
+                    ],
+                );
+                $routes->get('delete', 'UserController::delete/$1', [
+                    'as' => 'user-delete',
+                    'filter' => 'permission:users-delete',
+                ]);
+            });
+        });
+
+        // My account
+        $routes->group('my-account', function ($routes): void {
+            $routes->get('/', 'MyAccountController', [
+                'as' => 'my-account',
+            ]);
+            $routes->get(
+                'change-password',
+                'MyAccountController::changePassword/$1',
+                [
+                    'as' => 'change-password',
+                ],
+            );
+            $routes->post('change-password', 'MyAccountController::attemptChange/$1');
+        });
+    },
+);
diff --git a/app/Controllers/Admin/BaseController.php b/modules/Admin/Controllers/BaseController.php
similarity index 96%
rename from app/Controllers/Admin/BaseController.php
rename to modules/Admin/Controllers/BaseController.php
index a5629f250bafaec3d3f323ea62d7ed888aca273c..205a4f4aa2c4d2ed4126a1d083ae06c2846617a3 100644
--- a/app/Controllers/Admin/BaseController.php
+++ b/modules/Admin/Controllers/BaseController.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use CodeIgniter\Controller;
 use CodeIgniter\HTTP\RequestInterface;
diff --git a/app/Controllers/Admin/ContributorController.php b/modules/Admin/Controllers/ContributorController.php
similarity index 93%
rename from app/Controllers/Admin/ContributorController.php
rename to modules/Admin/Controllers/ContributorController.php
index f059163a9b04c0e322a23a3678f55622bcbc169b..47a825c304e3f051fae413a6e0df052447cf5ffd 100644
--- a/app/Controllers/Admin/ContributorController.php
+++ b/modules/Admin/Controllers/ContributorController.php
@@ -8,16 +8,16 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
-use App\Authorization\GroupModel;
 use App\Entities\Podcast;
-use App\Entities\User;
 use App\Models\PodcastModel;
 use App\Models\UserModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use Exception;
+use Modules\Auth\Authorization\GroupModel;
+use Modules\Auth\Entities\User;
 
 class ContributorController extends BaseController
 {
@@ -57,7 +57,7 @@ class ContributorController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/contributor/list', $data);
+        return view('Modules\Admin\Views\contributor\list', $data);
     }
 
     public function view(): string
@@ -70,7 +70,7 @@ class ContributorController extends BaseController
             0 => $this->podcast->title,
             1 => $this->user->username,
         ]);
-        return view('admin/contributor/view', $data);
+        return view('Modules\Admin\Views\contributor\view', $data);
     }
 
     public function add(): string
@@ -106,7 +106,7 @@ class ContributorController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/contributor/add', $data);
+        return view('Modules\Admin\Views\contributor\add', $data);
     }
 
     public function attemptAdd(): RedirectResponse
@@ -155,7 +155,7 @@ class ContributorController extends BaseController
             0 => $this->podcast->title,
             1 => $this->user->username,
         ]);
-        return view('admin/contributor/edit', $data);
+        return view('Modules\Admin\Views\contributor\edit', $data);
     }
 
     public function attemptEdit(): RedirectResponse
diff --git a/app/Controllers/Admin/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
similarity index 97%
rename from app/Controllers/Admin/EpisodeController.php
rename to modules/Admin/Controllers/EpisodeController.php
index fce864bd1b33943f6199fa19980e0efc7f4016d8..47846cc999e09a104ee146483c87204d746f6574 100644
--- a/app/Controllers/Admin/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Episode;
 use App\Entities\EpisodeComment;
@@ -77,7 +77,7 @@ class EpisodeController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/episode/list', $data);
+        return view('Modules\Admin\Views\episode\list', $data);
     }
 
     public function view(): string
@@ -91,7 +91,7 @@ class EpisodeController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/view', $data);
+        return view('Modules\Admin\Views\episode\view', $data);
     }
 
     public function create(): string
@@ -105,7 +105,7 @@ class EpisodeController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/episode/create', $data);
+        return view('Modules\Admin\Views\episode\create', $data);
     }
 
     public function attemptCreate(): RedirectResponse
@@ -230,7 +230,7 @@ class EpisodeController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/edit', $data);
+        return view('Modules\Admin\Views\episode\edit', $data);
     }
 
     public function attemptEdit(): RedirectResponse
@@ -404,7 +404,7 @@ class EpisodeController extends BaseController
                 0 => $this->podcast->title,
                 1 => $this->episode->title,
             ]);
-            return view('admin/episode/publish', $data);
+            return view('Modules\Admin\Views\episode\publish', $data);
         }
 
         return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
@@ -503,7 +503,7 @@ class EpisodeController extends BaseController
                 0 => $this->podcast->title,
                 1 => $this->episode->title,
             ]);
-            return view('admin/episode/publish_edit', $data);
+            return view('Modules\Admin\Views\episode\publish_edit', $data);
         }
 
         return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
@@ -632,7 +632,7 @@ class EpisodeController extends BaseController
                 0 => $this->podcast->title,
                 1 => $this->episode->title,
             ]);
-            return view('admin/episode/unpublish', $data);
+            return view('Modules\Admin\Views\episode\unpublish', $data);
         }
 
         return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
@@ -704,7 +704,7 @@ class EpisodeController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/soundbites', $data);
+        return view('Modules\Admin\Views\episode\soundbites', $data);
     }
 
     public function soundbitesAttemptEdit(): RedirectResponse
@@ -782,7 +782,7 @@ class EpisodeController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/embeddable_player', $data);
+        return view('Modules\Admin\Views\episode\embeddable_player', $data);
     }
 
     public function attemptCommentCreate(): RedirectResponse
diff --git a/app/Controllers/Admin/EpisodePersonController.php b/modules/Admin/Controllers/EpisodePersonController.php
similarity index 95%
rename from app/Controllers/Admin/EpisodePersonController.php
rename to modules/Admin/Controllers/EpisodePersonController.php
index 771311fec09a54c11068e15d6871816bab1f959e..860b9ca945d4d933e6574dea36fdc4127fac0a52 100644
--- a/app/Controllers/Admin/EpisodePersonController.php
+++ b/modules/Admin/Controllers/EpisodePersonController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Episode;
 use App\Entities\Podcast;
@@ -62,7 +62,7 @@ class EpisodePersonController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/persons', $data);
+        return view('Modules\Admin\Views\episode\persons', $data);
     }
 
     public function attemptAdd(): RedirectResponse
diff --git a/app/Controllers/Admin/FediverseController.php b/modules/Admin/Controllers/FediverseController.php
similarity index 75%
rename from app/Controllers/Admin/FediverseController.php
rename to modules/Admin/Controllers/FediverseController.php
index a51dc095cd378656210cbc62d352c28fac36239b..c1dbd41a748341857a276cb74fb21ec79a8fee36 100644
--- a/app/Controllers/Admin/FediverseController.php
+++ b/modules/Admin/Controllers/FediverseController.php
@@ -8,13 +8,13 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 class FediverseController extends BaseController
 {
     public function dashboard(): string
     {
-        return view('admin/fediverse/dashboard');
+        return view('Modules\Admin\Views\fediverse\dashboard');
     }
 
     public function blockedActors(): string
@@ -24,7 +24,7 @@ class FediverseController extends BaseController
         $blockedActors = model('ActorModel')
             ->getBlockedActors();
 
-        return view('admin/fediverse/blocked_actors', [
+        return view('Modules\Admin\Views\fediverse\blocked_actors', [
             'blockedActors' => $blockedActors,
         ]);
     }
@@ -36,7 +36,7 @@ class FediverseController extends BaseController
         $blockedDomains = model('BlockedDomainModel')
             ->getBlockedDomains();
 
-        return view('admin/fediverse/blocked_domains', [
+        return view('Modules\Admin\Views\fediverse\blocked_domains', [
             'blockedDomains' => $blockedDomains,
         ]);
     }
diff --git a/app/Controllers/Admin/HomeController.php b/modules/Admin/Controllers/HomeController.php
similarity index 91%
rename from app/Controllers/Admin/HomeController.php
rename to modules/Admin/Controllers/HomeController.php
index f0db00f0a3f3812d9e32380b83e8d38029b46d18..776b58299a58ca0ff655609b691770e0a033e6b1 100644
--- a/app/Controllers/Admin/HomeController.php
+++ b/modules/Admin/Controllers/HomeController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use CodeIgniter\HTTP\RedirectResponse;
 
diff --git a/app/Controllers/Admin/MyAccountController.php b/modules/Admin/Controllers/MyAccountController.php
similarity index 91%
rename from app/Controllers/Admin/MyAccountController.php
rename to modules/Admin/Controllers/MyAccountController.php
index 84ddc14df0b34596c91f558a6458fda672b28fbb..89fa6d4111ac8716e4b54221c12b0de5b879f106 100644
--- a/app/Controllers/Admin/MyAccountController.php
+++ b/modules/Admin/Controllers/MyAccountController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Models\UserModel;
 use CodeIgniter\HTTP\RedirectResponse;
@@ -18,14 +18,14 @@ class MyAccountController extends BaseController
 {
     public function index(): string
     {
-        return view('admin/my_account/view');
+        return view('Modules\Admin\Views\my_account\view');
     }
 
     public function changePassword(): string
     {
         helper('form');
 
-        return view('admin/my_account/change_password');
+        return view('Modules\Admin\Views\my_account\change_password');
     }
 
     public function attemptChange(): RedirectResponse
diff --git a/app/Controllers/Admin/PageController.php b/modules/Admin/Controllers/PageController.php
similarity index 91%
rename from app/Controllers/Admin/PageController.php
rename to modules/Admin/Controllers/PageController.php
index c2bd691ad1ee626ea9433515b66f56767e67bea3..4071f4080dba6940decd9c2bba5c843df09ae75d 100644
--- a/app/Controllers/Admin/PageController.php
+++ b/modules/Admin/Controllers/PageController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Page;
 use App\Models\PageModel;
@@ -38,12 +38,12 @@ class PageController extends BaseController
             'pages' => (new PageModel())->findAll(),
         ];
 
-        return view('admin/page/list', $data);
+        return view('Modules\Admin\Views\page\list', $data);
     }
 
     public function view(): string
     {
-        return view('admin/page/view', [
+        return view('Modules\Admin\Views\page\view', [
             'page' => $this->page,
         ]);
     }
@@ -52,7 +52,7 @@ class PageController extends BaseController
     {
         helper('form');
 
-        return view('admin/page/create');
+        return view('Modules\Admin\Views\page\create');
     }
 
     public function attemptCreate(): RedirectResponse
@@ -86,7 +86,7 @@ class PageController extends BaseController
         replace_breadcrumb_params([
             0 => $this->page->title,
         ]);
-        return view('admin/page/edit', [
+        return view('Modules\Admin\Views\page\edit', [
             'page' => $this->page,
         ]);
     }
diff --git a/app/Controllers/Admin/PersonController.php b/modules/Admin/Controllers/PersonController.php
similarity index 93%
rename from app/Controllers/Admin/PersonController.php
rename to modules/Admin/Controllers/PersonController.php
index 669c965067c985d3290eae0a06552ceda7161177..1aa8cb46b4766c455852afa5c5880b11fabe028c 100644
--- a/app/Controllers/Admin/PersonController.php
+++ b/modules/Admin/Controllers/PersonController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Image;
 use App\Entities\Person;
@@ -41,7 +41,7 @@ class PersonController extends BaseController
             'persons' => (new PersonModel())->findAll(),
         ];
 
-        return view('admin/person/list', $data);
+        return view('Modules\Admin\Views\person\list', $data);
     }
 
     public function view(): string
@@ -53,14 +53,14 @@ class PersonController extends BaseController
         replace_breadcrumb_params([
             0 => $this->person->full_name,
         ]);
-        return view('admin/person/view', $data);
+        return view('Modules\Admin\Views\person\view', $data);
     }
 
     public function create(): string
     {
         helper(['form']);
 
-        return view('admin/person/create');
+        return view('Modules\Admin\Views\person\create');
     }
 
     public function attemptCreate(): RedirectResponse
@@ -113,7 +113,7 @@ class PersonController extends BaseController
         replace_breadcrumb_params([
             0 => $this->person->full_name,
         ]);
-        return view('admin/person/edit', $data);
+        return view('Modules\Admin\Views\person\edit', $data);
     }
 
     public function attemptEdit(): RedirectResponse
diff --git a/app/Controllers/Admin/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
similarity index 92%
rename from app/Controllers/Admin/PodcastController.php
rename to modules/Admin/Controllers/PodcastController.php
index 884e57e5f5c2c79f7a57b02b829f82861e48905f..34c76228bcd5003a6131e8866928e789fc4b61e4 100644
--- a/app/Controllers/Admin/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Image;
 use App\Entities\Location;
@@ -53,7 +53,7 @@ class PodcastController extends BaseController
             ];
         }
 
-        return view('admin/podcast/list', $data);
+        return view('Modules\Admin\Views\podcast\list', $data);
     }
 
     public function view(): string
@@ -65,7 +65,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/view', $data);
+        return view('Modules\Admin\Views\podcast\view', $data);
     }
 
     public function viewAnalytics(): string
@@ -77,7 +77,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/index', $data);
+        return view('Modules\Admin\Views\podcast\analytics\index', $data);
     }
 
     public function viewAnalyticsWebpages(): string
@@ -89,7 +89,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/webpages', $data);
+        return view('Modules\Admin\Views\podcast\analytics\webpages', $data);
     }
 
     public function viewAnalyticsLocations(): string
@@ -101,7 +101,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/locations', $data);
+        return view('Modules\Admin\Views\podcast\analytics\locations', $data);
     }
 
     public function viewAnalyticsUniqueListeners(): string
@@ -113,7 +113,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/unique_listeners', $data);
+        return view('Modules\Admin\Views\podcast\analytics\unique_listeners', $data);
     }
 
     public function viewAnalyticsListeningTime(): string
@@ -125,7 +125,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/listening_time', $data);
+        return view('Modules\Admin\Views\podcast\analytics\listening_time', $data);
     }
 
     public function viewAnalyticsTimePeriods(): string
@@ -137,7 +137,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/time_periods', $data);
+        return view('Modules\Admin\Views\podcast\analytics\time_periods', $data);
     }
 
     public function viewAnalyticsPlayers(): string
@@ -149,7 +149,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/analytics/players', $data);
+        return view('Modules\Admin\Views\podcast\analytics\players', $data);
     }
 
     public function create(): string
@@ -165,7 +165,7 @@ class PodcastController extends BaseController
             'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
         ];
 
-        return view('admin/podcast/create', $data);
+        return view('Modules\Admin\Views\podcast\create', $data);
     }
 
     public function attemptCreate(): RedirectResponse
@@ -274,7 +274,7 @@ class PodcastController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/edit', $data);
+        return view('Modules\Admin\Views\podcast\edit', $data);
     }
 
     public function attemptEdit(): RedirectResponse
@@ -364,7 +364,7 @@ class PodcastController extends BaseController
             ->orderBy('created_at', 'desc')
             ->findAll($limit);
 
-        return view('admin/podcast/latest_episodes', [
+        return view('Modules\Admin\Views\podcast\latest_episodes', [
             'episodes' => $episodes,
         ]);
     }
diff --git a/app/Controllers/Admin/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
similarity index 99%
rename from app/Controllers/Admin/PodcastImportController.php
rename to modules/Admin/Controllers/PodcastImportController.php
index ffeb32488fc2a09de0c109471cd547db639f5510..0d84686134c7a27e9bf57def172cb43e22c9d8f8 100644
--- a/app/Controllers/Admin/PodcastImportController.php
+++ b/modules/Admin/Controllers/PodcastImportController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Episode;
 use App\Entities\Image;
@@ -58,7 +58,7 @@ class PodcastImportController extends BaseController
             'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
         ];
 
-        return view('admin/podcast/import', $data);
+        return view('Modules\Admin\Views\podcast\import', $data);
     }
 
     public function attemptImport(): RedirectResponse
diff --git a/app/Controllers/Admin/PodcastPersonController.php b/modules/Admin/Controllers/PodcastPersonController.php
similarity index 95%
rename from app/Controllers/Admin/PodcastPersonController.php
rename to modules/Admin/Controllers/PodcastPersonController.php
index 5572e5a211845937f561c99b69eeac23cb6619e8..2704018eb5ef4ea3ef89eb375bc52a04ee3f632b 100644
--- a/app/Controllers/Admin/PodcastPersonController.php
+++ b/modules/Admin/Controllers/PodcastPersonController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Podcast;
 use App\Models\PersonModel;
@@ -49,7 +49,7 @@ class PodcastPersonController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/persons', $data);
+        return view('Modules\Admin\Views\podcast\persons', $data);
     }
 
     public function attemptAdd(): RedirectResponse
diff --git a/app/Controllers/Admin/PodcastPlatformController.php b/modules/Admin/Controllers/PodcastPlatformController.php
similarity index 94%
rename from app/Controllers/Admin/PodcastPlatformController.php
rename to modules/Admin/Controllers/PodcastPlatformController.php
index 370bad5546623c7d709ed4539bc5f4536510a433..568fc3810f14baf5c5229f7a04b3ae2ef648db66 100644
--- a/app/Controllers/Admin/PodcastPlatformController.php
+++ b/modules/Admin/Controllers/PodcastPlatformController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
 use App\Entities\Podcast;
 use App\Models\PlatformModel;
@@ -39,7 +39,7 @@ class PodcastPlatformController extends BaseController
 
     public function index(): string
     {
-        return view('admin/podcast/platforms/dashboard');
+        return view('Modules\Admin\Views\podcast\platforms\dashboard');
     }
 
     public function platforms(string $platformType): string
@@ -55,7 +55,7 @@ class PodcastPlatformController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/platforms', $data);
+        return view('Modules\Admin\Views\podcast\platforms', $data);
     }
 
     public function attemptPlatformsUpdate(string $platformType): RedirectResponse
diff --git a/app/Controllers/Admin/UserController.php b/modules/Admin/Controllers/UserController.php
similarity index 94%
rename from app/Controllers/Admin/UserController.php
rename to modules/Admin/Controllers/UserController.php
index 8b68cddc9a19fc7c44b055c31e6ddb605879f9f8..5ce0d32754dbe4a06dc37395e28a03c3a908df52 100644
--- a/app/Controllers/Admin/UserController.php
+++ b/modules/Admin/Controllers/UserController.php
@@ -8,14 +8,14 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers\Admin;
+namespace Modules\Admin\Controllers;
 
-use App\Authorization\GroupModel;
-use App\Entities\User;
 use App\Models\UserModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use Config\Services;
+use Modules\Auth\Authorization\GroupModel;
+use Modules\Auth\Entities\User;
 
 class UserController extends BaseController
 {
@@ -40,7 +40,7 @@ class UserController extends BaseController
             'users' => (new UserModel())->findAll(),
         ];
 
-        return view('admin/user/list', $data);
+        return view('Modules\Admin\Views\user\list', $data);
     }
 
     public function view(): string
@@ -52,7 +52,7 @@ class UserController extends BaseController
         replace_breadcrumb_params([
             0 => $this->user->username,
         ]);
-        return view('admin/user/view', $data);
+        return view('Modules\Admin\Views\user\view', $data);
     }
 
     public function create(): string
@@ -63,7 +63,7 @@ class UserController extends BaseController
             'roles' => (new GroupModel())->getUserRoles(),
         ];
 
-        return view('admin/user/create', $data);
+        return view('Modules\Admin\Views\user\create', $data);
     }
 
     public function attemptCreate(): RedirectResponse
@@ -135,7 +135,7 @@ class UserController extends BaseController
         replace_breadcrumb_params([
             0 => $this->user->username,
         ]);
-        return view('admin/user/edit', $data);
+        return view('Modules\Admin\Views\user\edit', $data);
     }
 
     public function attemptEdit(): RedirectResponse
diff --git a/modules/Admin/Language/.gitkeep b/modules/Admin/Language/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/modules/Admin/Language/en/Admin.php b/modules/Admin/Language/en/Admin.php
new file mode 100644
index 0000000000000000000000000000000000000000..944e7af2b6d1cd0500155e1549d468820c79f93c
--- /dev/null
+++ b/modules/Admin/Language/en/Admin.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'dashboard' => 'Admin dashboard',
+    'welcome_message' => 'Welcome to the admin area!',
+    'choose_interact' => 'Choose how to interact',
+];
diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php
new file mode 100644
index 0000000000000000000000000000000000000000..a0fa5e44e707699d601d6f7d285d176e8e6b1034
--- /dev/null
+++ b/modules/Admin/Language/en/Breadcrumb.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'label' => 'breadcrumb',
+    config('Admin')
+        ->gateway => 'Home',
+    'podcasts' => 'podcasts',
+    'episodes' => 'episodes',
+    'contributors' => 'contributors',
+    'pages' => 'pages',
+    'add' => 'add',
+    'new' => 'new',
+    'edit' => 'edit',
+    'persons' => 'persons',
+    'publish' => 'publish',
+    'publish-edit' => 'edit publication',
+    'unpublish' => 'unpublish',
+    'fediverse' => 'fediverse',
+    'block-lists' => 'block lists',
+    'users' => 'users',
+    'my-account' => 'my account',
+    'change-password' => 'change password',
+    'import' => 'feed import',
+    'platforms' => 'platforms',
+    'social' => 'social networks',
+    'funding' => 'funding',
+    'analytics' => 'analytics',
+    'locations' => 'locations',
+    'webpages' => 'web pages',
+    'unique-listeners' => 'unique listeners',
+    'players' => 'players',
+    'listening-time' => 'listening time',
+    'time-periods' => 'time periods',
+    'soundbites' => 'soundbites',
+    'embeddable-player' => 'embeddable player',
+];
diff --git a/modules/Admin/Language/en/Charts.php b/modules/Admin/Language/en/Charts.php
new file mode 100644
index 0000000000000000000000000000000000000000..2bc933ef280659a76095b6957442c8cda203a258
--- /dev/null
+++ b/modules/Admin/Language/en/Charts.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'by_service_weekly' => 'Episode downloads by service (for the past week)',
+    'by_player_weekly' => 'Episode downloads by player (for the past week)',
+    'by_player_yearly' => 'Episode downloads by player (for the past year)',
+    'by_device_weekly' => 'Episode downloads by device (for the past week)',
+    'by_os_weekly' => 'Episode downloads by O.S. (for the past week)',
+    'podcast_by_region' => 'Episode downloads by region (for the past week)',
+    'unique_daily_listeners' => 'Daily unique listeners',
+    'unique_monthly_listeners' => 'Monthly unique listeners',
+    'by_browser' => 'Web pages usage by browser (for the past week)',
+    'podcast_by_day' => 'Episode daily downloads',
+    'podcast_by_month' => 'Episode monthly downloads',
+    'episode_by_day' => 'Episode daily downloads (first 60 days)',
+    'episode_by_month' => 'Episode monthly downloads',
+    'episodes_by_day' =>
+        '5 latest episodes downloads (during their first 60 days)',
+    'by_country_weekly' => 'Episode downloads by country (for the past week)',
+    'by_country_yearly' => 'Episode downloads by country (for the past year)',
+    'by_domain_weekly' => 'Web pages visits by source (for the past week)',
+    'by_domain_yearly' => 'Web pages visits by source (for the past year)',
+    'by_entry_page' => 'Web pages visits by landing page (for the past week)',
+    'podcast_bots' => 'Bots (crawlers)',
+    'daily_listening_time' => 'Daily cumulative listening time',
+    'monthly_listening_time' => 'Monthly cumulative listening time',
+    'by_weekday' => 'By week day (for the past 60 days)',
+    'by_hour' => 'By time of day (for the past 60 days)',
+    'podcast_by_bandwidth' => 'Daily used bandwidth (in MB)',
+];
diff --git a/modules/Admin/Language/en/Common.php b/modules/Admin/Language/en/Common.php
new file mode 100644
index 0000000000000000000000000000000000000000..f05e5d6a7c749ed2ca2f01cf34aabb63f0beee7f
--- /dev/null
+++ b/modules/Admin/Language/en/Common.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'yes' => 'Yes',
+    'no' => 'No',
+    'cancel' => 'Cancel',
+    'optional' => 'Optional',
+    'more' => 'More',
+    'no_data' => 'No data found!',
+    'close' => 'Close',
+    'edit' => 'Edit',
+    'copy' => 'Copy',
+    'copied' => 'Copied!',
+    'home' => 'Home',
+    'explicit' => 'Explicit',
+    'mediumDate' => '{0,date,medium}',
+    'powered_by' => 'Powered by {castopod}.',
+    'actions' => 'Actions',
+    'pageInfo' => 'Page {currentPage} out of {pageCount}',
+    'go_back' => 'Go back',
+    'forms' => [
+        'editor' => [
+            'write' => 'Write',
+            'preview' => 'Preview',
+            'help' => 'Powered by markdown',
+        ],
+        'multiSelect' => [
+            'selectText' => 'Press to select',
+            'loadingText' => 'Loading...',
+            'noResultsText' => 'No results found',
+            'noChoicesText' => 'No choices to choose from',
+            'maxItemText' => 'Cannot add more items',
+        ],
+        'image_size_hint' =>
+            'Image must be squared with at least 1400px wide and tall.',
+        'upload_file' => 'Upload a file',
+        'remote_url' => 'Remote URL',
+    ],
+    'play_episode_button' => [
+        'play' => 'Play',
+        'playing' => 'Playing',
+    ],
+];
diff --git a/modules/Admin/Language/en/Contributor.php b/modules/Admin/Language/en/Contributor.php
new file mode 100644
index 0000000000000000000000000000000000000000..087e4f6d5f5231feea878994c8d733f719817a70
--- /dev/null
+++ b/modules/Admin/Language/en/Contributor.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'podcast_contributors' => 'Podcast contributors',
+    'view' => "{username}'s contribution to {podcastTitle}",
+    'add' => 'Add contributor',
+    'add_contributor' => 'Add a contributor for {0}',
+    'edit_role' => 'Update role for {0}',
+    'edit' => 'Edit',
+    'remove' => 'Remove',
+    'list' => [
+        'username' => 'Username',
+        'role' => 'Role',
+    ],
+    'form' => [
+        'user' => 'User',
+        'user_placeholder' => 'Select a user…',
+        'role' => 'Role',
+        'role_placeholder' => 'Select its role…',
+        'submit_add' => 'Add contributor',
+        'submit_edit' => 'Update role',
+    ],
+    'roles' => [
+        'podcast_admin' => 'Podcast admin',
+    ],
+    'messages' => [
+        'removeOwnerContributorError' => "You can't remove the podcast owner!",
+        'removeContributorSuccess' =>
+            'You have successfully removed {username} from {podcastTitle}',
+        'alreadyAddedError' =>
+            "The contributor you're trying to add has already been added!",
+    ],
+];
diff --git a/modules/Admin/Language/en/Countries.php b/modules/Admin/Language/en/Countries.php
new file mode 100644
index 0000000000000000000000000000000000000000..b682d306f711ccac6068ded43a8538494ebdcb16
--- /dev/null
+++ b/modules/Admin/Language/en/Countries.php
@@ -0,0 +1,264 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * ISO 3166 country codes
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'AD' => 'Andorra',
+    'AE' => 'United Arab Emirates',
+    'AF' => 'Afghanistan',
+    'AG' => 'Antigua and Barbuda',
+    'AI' => 'Anguilla',
+    'AL' => 'Albania',
+    'AM' => 'Armenia',
+    'AO' => 'Angola',
+    'AQ' => 'Antarctica',
+    'AR' => 'Argentina',
+    'AS' => 'American Samoa',
+    'AT' => 'Austria',
+    'AU' => 'Australia',
+    'AW' => 'Aruba',
+    'AX' => 'Ã…land Islands',
+    'AZ' => 'Azerbaijan',
+    'BA' => 'Bosnia and Herzegovina',
+    'BB' => 'Barbados',
+    'BD' => 'Bangladesh',
+    'BE' => 'Belgium',
+    'BF' => 'Burkina Faso',
+    'BG' => 'Bulgaria',
+    'BH' => 'Bahrain',
+    'BI' => 'Burundi',
+    'BJ' => 'Benin',
+    'BL' => 'Saint Barthélemy',
+    'BM' => 'Bermuda',
+    'BN' => 'Brunei Darussalam',
+    'BO' => 'Bolivia, Plurinational State of',
+    'BQ' => 'Bonaire, Sint Eustatius and Saba',
+    'BR' => 'Brazil',
+    'BS' => 'Bahamas',
+    'BT' => 'Bhutan',
+    'BV' => 'Bouvet Island',
+    'BW' => 'Botswana',
+    'BY' => 'Belarus',
+    'BZ' => 'Belize',
+    'CA' => 'Canada',
+    'CC' => 'Cocos (Keeling) Islands',
+    'CD' => 'Congo, the Democratic Republic of the',
+    'CF' => 'Central African Republic',
+    'CG' => 'Congo',
+    'CH' => 'Switzerland',
+    'CI' => "Côte d'Ivoire",
+    'CK' => 'Cook Islands',
+    'CL' => 'Chile',
+    'CM' => 'Cameroon',
+    'CN' => 'China',
+    'CO' => 'Colombia',
+    'CR' => 'Costa Rica',
+    'CU' => 'Cuba',
+    'CV' => 'Cape Verde',
+    'CW' => 'Curaçao',
+    'CX' => 'Christmas Island',
+    'CY' => 'Cyprus',
+    'CZ' => 'Czech Republic',
+    'DE' => 'Germany',
+    'DJ' => 'Djibouti',
+    'DK' => 'Denmark',
+    'DM' => 'Dominica',
+    'DO' => 'Dominican Republic',
+    'DZ' => 'Algeria',
+    'EC' => 'Ecuador',
+    'EE' => 'Estonia',
+    'EG' => 'Egypt',
+    'EH' => 'Western Sahara',
+    'ER' => 'Eritrea',
+    'ES' => 'Spain',
+    'ET' => 'Ethiopia',
+    'FI' => 'Finland',
+    'FJ' => 'Fiji',
+    'FK' => 'Falkland Islands (Malvinas)',
+    'FM' => 'Micronesia, Federated States of',
+    'FO' => 'Faroe Islands',
+    'FR' => 'France',
+    'GA' => 'Gabon',
+    'GB' => 'United Kingdom',
+    'GD' => 'Grenada',
+    'GE' => 'Georgia',
+    'GF' => 'French Guiana',
+    'GG' => 'Guernsey',
+    'GH' => 'Ghana',
+    'GI' => 'Gibraltar',
+    'GL' => 'Greenland',
+    'GM' => 'Gambia',
+    'GN' => 'Guinea',
+    'GP' => 'Guadeloupe',
+    'GQ' => 'Equatorial Guinea',
+    'GR' => 'Greece',
+    'GS' => 'South Georgia and the South Sandwich Islands',
+    'GT' => 'Guatemala',
+    'GU' => 'Guam',
+    'GW' => 'Guinea-Bissau',
+    'GY' => 'Guyana',
+    'HK' => 'Hong Kong',
+    'HM' => 'Heard Island and McDonald Islands',
+    'HN' => 'Honduras',
+    'HR' => 'Croatia',
+    'HT' => 'Haiti',
+    'HU' => 'Hungary',
+    'ID' => 'Indonesia',
+    'IE' => 'Ireland',
+    'IL' => 'Israel',
+    'IM' => 'Isle of Man',
+    'IN' => 'India',
+    'IO' => 'British Indian Ocean Territory',
+    'IQ' => 'Iraq',
+    'IR' => 'Iran, Islamic Republic of',
+    'IS' => 'Iceland',
+    'IT' => 'Italy',
+    'JE' => 'Jersey',
+    'JM' => 'Jamaica',
+    'JO' => 'Jordan',
+    'JP' => 'Japan',
+    'KE' => 'Kenya',
+    'KG' => 'Kyrgyzstan',
+    'KH' => 'Cambodia',
+    'KI' => 'Kiribati',
+    'KM' => 'Comoros',
+    'KN' => 'Saint Kitts and Nevis',
+    'KP' => "Korea, Democratic People's Republic of",
+    'KR' => 'Korea, Republic of',
+    'KW' => 'Kuwait',
+    'KY' => 'Cayman Islands',
+    'KZ' => 'Kazakhstan',
+    'LA' => "Lao People's Democratic Republic",
+    'LB' => 'Lebanon',
+    'LC' => 'Saint Lucia',
+    'LI' => 'Liechtenstein',
+    'LK' => 'Sri Lanka',
+    'LR' => 'Liberia',
+    'LS' => 'Lesotho',
+    'LT' => 'Lithuania',
+    'LU' => 'Luxembourg',
+    'LV' => 'Latvia',
+    'LY' => 'Libya',
+    'MA' => 'Morocco',
+    'MC' => 'Monaco',
+    'MD' => 'Moldova, Republic of',
+    'ME' => 'Montenegro',
+    'MF' => 'Saint Martin (French part)',
+    'MG' => 'Madagascar',
+    'MH' => 'Marshall Islands',
+    'MK' => 'Macedonia, the Former Yugoslav Republic of',
+    'ML' => 'Mali',
+    'MM' => 'Myanmar',
+    'MN' => 'Mongolia',
+    'MO' => 'Macao',
+    'MP' => 'Northern Mariana Islands',
+    'MQ' => 'Martinique',
+    'MR' => 'Mauritania',
+    'MS' => 'Montserrat',
+    'MT' => 'Malta',
+    'MU' => 'Mauritius',
+    'MV' => 'Maldives',
+    'MW' => 'Malawi',
+    'MX' => 'Mexico',
+    'MY' => 'Malaysia',
+    'MZ' => 'Mozambique',
+    'N/A' => 'Not Applicable (local IP…)',
+    'NA' => 'Namibia',
+    'NC' => 'New Caledonia',
+    'NE' => 'Niger',
+    'NF' => 'Norfolk Island',
+    'NG' => 'Nigeria',
+    'NI' => 'Nicaragua',
+    'NL' => 'Netherlands',
+    'NO' => 'Norway',
+    'NP' => 'Nepal',
+    'NR' => 'Nauru',
+    'NU' => 'Niue',
+    'NZ' => 'New Zealand',
+    'OM' => 'Oman',
+    'PA' => 'Panama',
+    'PE' => 'Peru',
+    'PF' => 'French Polynesia',
+    'PG' => 'Papua New Guinea',
+    'PH' => 'Philippines',
+    'PK' => 'Pakistan',
+    'PL' => 'Poland',
+    'PM' => 'Saint Pierre and Miquelon',
+    'PN' => 'Pitcairn',
+    'PR' => 'Puerto Rico',
+    'PS' => 'Palestine, State of',
+    'PT' => 'Portugal',
+    'PW' => 'Palau',
+    'PY' => 'Paraguay',
+    'QA' => 'Qatar',
+    'RE' => 'Réunion',
+    'RO' => 'Romania',
+    'RS' => 'Serbia',
+    'RU' => 'Russian Federation',
+    'RW' => 'Rwanda',
+    'SA' => 'Saudi Arabia',
+    'SB' => 'Solomon Islands',
+    'SC' => 'Seychelles',
+    'SD' => 'Sudan',
+    'SE' => 'Sweden',
+    'SG' => 'Singapore',
+    'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
+    'SI' => 'Slovenia',
+    'SJ' => 'Svalbard and Jan Mayen',
+    'SK' => 'Slovakia',
+    'SL' => 'Sierra Leone',
+    'SM' => 'San Marino',
+    'SN' => 'Senegal',
+    'SO' => 'Somalia',
+    'SR' => 'Suriname',
+    'SS' => 'South Sudan',
+    'ST' => 'Sao Tome and Principe',
+    'SV' => 'El Salvador',
+    'SX' => 'Sint Maarten (Dutch part)',
+    'SY' => 'Syrian Arab Republic',
+    'SZ' => 'Swaziland',
+    'TC' => 'Turks and Caicos Islands',
+    'TD' => 'Chad',
+    'TF' => 'French Southern Territories',
+    'TG' => 'Togo',
+    'TH' => 'Thailand',
+    'TJ' => 'Tajikistan',
+    'TK' => 'Tokelau',
+    'TL' => 'Timor-Leste',
+    'TM' => 'Turkmenistan',
+    'TN' => 'Tunisia',
+    'TO' => 'Tonga',
+    'TR' => 'Turkey',
+    'TT' => 'Trinidad and Tobago',
+    'TV' => 'Tuvalu',
+    'TW' => 'Taiwan, Province of China',
+    'TZ' => 'Tanzania, United Republic of',
+    'UA' => 'Ukraine',
+    'UG' => 'Uganda',
+    'UM' => 'United States Minor Outlying Islands',
+    'US' => 'United States',
+    'UY' => 'Uruguay',
+    'UZ' => 'Uzbekistan',
+    'VA' => 'Holy See (Vatican City State)',
+    'VC' => 'Saint Vincent and the Grenadines',
+    'VE' => 'Venezuela, Bolivarian Republic of',
+    'VG' => 'Virgin Islands, British',
+    'VI' => 'Virgin Islands, U.S.',
+    'VN' => 'Viet Nam',
+    'VU' => 'Vanuatu',
+    'WF' => 'Wallis and Futuna',
+    'WS' => 'Samoa',
+    'YE' => 'Yemen',
+    'YT' => 'Mayotte',
+    'ZA' => 'South Africa',
+    'ZM' => 'Zambia',
+    'ZW' => 'Zimbabwe',
+];
diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php
new file mode 100644
index 0000000000000000000000000000000000000000..06855b9d713b30e269d58a8f0d54e5260af1fa87
--- /dev/null
+++ b/modules/Admin/Language/en/Episode.php
@@ -0,0 +1,174 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'season' => 'Season {seasonNumber}',
+    'season_abbr' => 'S{seasonNumber}',
+    'number' => 'Episode {episodeNumber}',
+    'number_abbr' => 'Ep. {episodeNumber}',
+    'season_episode' => 'Season {seasonNumber} episode {episodeNumber}',
+    'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}',
+    'back_to_episodes' => 'Back to episodes of {podcast}',
+    'comments' => 'Comments',
+    'activity' => 'Activity',
+    'description' => 'Description',
+    'number_of_comments' => '{numberOfComments, plural,
+        one {# comment}
+        other {# comments}
+    }',
+    'all_podcast_episodes' => 'All podcast episodes',
+    'back_to_podcast' => 'Go back to podcast',
+    'edit' => 'Edit',
+    'publish' => 'Publish',
+    'publish_edit' => 'Edit publication',
+    'unpublish' => 'Unpublish',
+    'publish_error' => 'Episode is already published.',
+    'publish_edit_error' => 'Episode is already published.',
+    'publish_cancel_error' => 'Episode is already published.',
+    'unpublish_error' => 'Episode is not published.',
+    'delete' => 'Delete',
+    'go_to_page' => 'Go to page',
+    'create' => 'Add an episode',
+    'publication_status' => [
+        'published' => 'Published',
+        'scheduled' => 'Scheduled',
+        'not_published' => 'Not published',
+    ],
+    'list' => [
+        'episode' => 'Episode',
+        'visibility' => 'Visibility',
+        'comments' => 'Comments',
+        'actions' => 'Actions',
+    ],
+    'form' => [
+        'warning' =>
+            'In case of fatal error, try increasing the `memory_limit`, `upload_max_filesize` and `post_max_size` values in your php configuration file then restart your web server.<br />These values must be higher than the audio file you wish to upload.',
+        'audio_file' => 'Audio file',
+        'audio_file_hint' => 'Choose an .mp3 or .m4a audio file.',
+        'info_section_title' => 'Episode info',
+        'info_section_subtitle' => '',
+        'image' => 'Cover image',
+        'image_hint' =>
+            'If you do not set an image, the podcast cover will be used instead.',
+        'title' => 'Title',
+        'title_hint' =>
+            'Should contain a clear and concise episode name. Do not specify the episode or season numbers here.',
+        'permalink' => 'Permalink',
+        'season_number' => 'Season',
+        'episode_number' => 'Episode',
+        'type' => [
+            'label' => 'Type',
+            'hint' =>
+                '- <strong>full</strong>: complete content the episode.<br/>- <strong>trailer</strong>: short, promotional piece of content that represents a preview of the current show.<br/>- <strong>bonus</strong>: extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show.',
+            'full' => 'Full',
+            'trailer' => 'Trailer',
+            'bonus' => 'Bonus',
+        ],
+        'parental_advisory' => [
+            'label' => 'Parental advisory',
+            'hint' => 'Does the episode contain explicit content?',
+            'undefined' => 'undefined',
+            'clean' => 'Clean',
+            'explicit' => 'Explicit',
+        ],
+        'show_notes_section_title' => 'Show notes',
+        'show_notes_section_subtitle' =>
+            'Up to 4000 characters, be clear and concise. Show notes help potential listeners in finding the episode.',
+        'description' => 'Description',
+        'description_footer' => 'Description footer',
+        'description_footer_hint' =>
+            'This text is added at the end of each episode description, it is a good place to input your social links for example.',
+        'additional_files_section_title' => 'Additional files',
+        'additional_files_section_subtitle' =>
+            'These files may be used by other platforms to provide better experience to your audience.<br />See the {podcastNamespaceLink} for more information.',
+        'location_section_title' => 'Location',
+        'location_section_subtitle' => 'What place is this episode about?',
+        'location_name' => 'Location name or address',
+        'location_name_hint' => 'This can be a real or fictional location',
+        'transcript' => 'Transcript or closed captions',
+        'transcript_hint' => 'Allowed formats are txt, html, srt or json.',
+        'transcript_file' => 'Transcript file',
+        'transcript_file_remote_url' => 'Remote url for transcript',
+        'transcript_file_delete' => 'Delete transcript file',
+        'chapters' => 'Chapters',
+        'chapters_hint' => 'File must be in JSON Chapters format.',
+        'chapters_file' => 'Chapters file',
+        'chapters_file_remote_url' => 'Remote url for chapters file',
+        'chapters_file_delete' => 'Delete chapters file',
+        'advanced_section_title' => 'Advanced Parameters',
+        'advanced_section_subtitle' =>
+            'If you need RSS tags that Castopod does not handle, set them here.',
+        'custom_rss' => 'Custom RSS tags for the episode',
+        'custom_rss_hint' => 'This will be injected within the ❬item❭ tag.',
+        'block' => 'Episode should be hidden from all platforms',
+        'block_hint' =>
+            'The episode show or hide post. If you want this episode removed from the Apple directory, toggle this on.',
+        'submit_create' => 'Create episode',
+        'submit_edit' => 'Save episode',
+    ],
+    'publish_form' => [
+        'back_to_episode_dashboard' => 'Back to episode dashboard',
+        'post' => 'Your announcement post',
+        'post_hint' =>
+            "Write a message to announce the publication of your episode. The message will be broadcasted to all your followers in the fediverse and be featured in your podcast's homepage.",
+        'publication_date' => 'Publication date',
+        'publication_method' => [
+            'now' => 'Now',
+            'schedule' => 'Schedule',
+        ],
+        'scheduled_publication_date' => 'Scheduled publication date',
+        'scheduled_publication_date_clear' => 'Clear publication date',
+        'scheduled_publication_date_hint' =>
+            'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
+        'submit' => 'Publish',
+        'submit_edit' => 'Edit publication',
+        'cancel_publication' => 'Cancel publication',
+        'message_warning' => 'You did not write a message for your announcement post!',
+        'message_warning_hint' => 'Having a message increases social engagement, resulting in a better visibility for your episode.',
+        'message_warning_submit' => 'Publish anyways',
+    ],
+    'unpublish_form' => [
+        'disclaimer' =>
+            "Unpublishing the episode will delete all the notes associated with the episode and remove it from the podcast's RSS feed.",
+        'understand' => 'I understand, I want to unpublish the episode',
+        'submit' => 'Unpublish',
+    ],
+    'soundbites' => 'Soundbites',
+    'soundbites_form' => [
+        'title' => 'Edit soundbites',
+        'info_section_title' => 'Episode soundbites',
+        'info_section_subtitle' => 'Add, edit or delete soundbites',
+        'start_time' => 'Start',
+        'start_time_hint' =>
+            'The first second of the soundbite, it can be a decimal number.',
+        'duration' => 'Duration',
+        'duration_hint' =>
+            'The duration of the soundbite (in seconds), it can be a decimal number.',
+        'label' => 'Label',
+        'label_hint' => 'Text that will be displayed.',
+        'play' => 'Play soundbite',
+        'delete' => 'Delete soundbite',
+        'bookmark' =>
+            'Click while playing to get current position, click again to get duration.',
+        'submit_edit' => 'Save all soundbites',
+    ],
+    'embeddable_player' => [
+        'add' => 'Add embeddable player',
+        'title' => 'Embeddable player',
+        'label' =>
+            'Pick a theme color, copy the embeddable player to clipboard, then paste it on your website.',
+        'clipboard_iframe' => 'Copy embeddable player to clipboard',
+        'clipboard_url' => 'Copy address to clipboard',
+        'dark' => 'Dark',
+        'dark-transparent' => 'Dark transparent',
+        'light' => 'Light',
+        'light-transparent' => 'Light transparent',
+    ],
+];
diff --git a/modules/Admin/Language/en/Fediverse.php b/modules/Admin/Language/en/Fediverse.php
new file mode 100644
index 0000000000000000000000000000000000000000..a445724791d24a411c138e4e8e921860edcad2e4
--- /dev/null
+++ b/modules/Admin/Language/en/Fediverse.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'blocked_actors' => 'Blocked accounts',
+    'blocked_domains' => 'Blocked domains',
+    'block_lists_form' => [
+        'handle' => 'Account handle',
+        'handle_hint' => 'Input @username@domain account.',
+        'domain' => 'Domain name',
+        'submit' => 'Block!',
+    ],
+    'list' => [
+        'actor' => 'Account',
+        'domain' => 'Domain name',
+        'unblock' => 'Unblock',
+    ],
+];
diff --git a/modules/Admin/Language/en/Home.php b/modules/Admin/Language/en/Home.php
new file mode 100644
index 0000000000000000000000000000000000000000..eda08142ef15ee9a08ff7529369a07fc079fa2b8
--- /dev/null
+++ b/modules/Admin/Language/en/Home.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'all_podcasts' => 'All podcasts',
+    'no_podcast' => 'No podcast found',
+];
diff --git a/modules/Admin/Language/en/Install.php b/modules/Admin/Language/en/Install.php
new file mode 100644
index 0000000000000000000000000000000000000000..c70faf69a56ec390d3bba9653a5d68bdfee36ba2
--- /dev/null
+++ b/modules/Admin/Language/en/Install.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'manual_config' => 'Manual configuration',
+    'manual_config_subtitle' =>
+        'Create a `.env` file with your settings and refresh the page to continue installation.',
+    'form' => [
+        'instance_config' => 'Instance configuration',
+        'hostname' => 'Hostname',
+        'media_base_url' => 'Media base URL',
+        'media_base_url_hint' =>
+            'If you use a CDN and/or an external analytics service, you may set them here.',
+        'admin_gateway' => 'Admin gateway',
+        'admin_gateway_hint' =>
+            'The route to access the admin area (eg. https://example.com/cp-admin). It is set by default as cp-admin, we recommend you change it for security reasons.',
+        'auth_gateway' => 'Auth gateway',
+        'auth_gateway_hint' =>
+            'The route to access the authentication pages (eg. https://example.com/cp-auth). It is set by default as cp-auth, we recommend you change it for security reasons.',
+        'database_config' => 'Database configuration',
+        'database_config_hint' =>
+            'Castopod needs to connect to your MySQL (or MariaDB) database. If you do not have these required info, please contact your server administrator.',
+        'db_hostname' => 'Database hostname',
+        'db_name' => 'Database name',
+        'db_username' => 'Database username',
+        'db_password' => 'Database password',
+        'db_prefix' => 'Database prefix',
+        'db_prefix_hint' =>
+            "The prefix of the Castopod table names, leave as is if you don't know what it means.",
+        'cache_config' => 'Cache configuration',
+        'cache_config_hint' =>
+            'Choose your preferred cache handler. Leave it as the default value if you have no clue what it means.',
+        'cache_handler' => 'Cache handler',
+        'cacheHandlerOptions' => [
+            'file' => 'File',
+            'redis' => 'Redis',
+            'predis' => 'Predis',
+        ],
+        'next' => 'Next',
+        'submit' => 'Finish install',
+        'create_superadmin' => 'Create your superadmin account',
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+    ],
+    'messages' => [
+        'createSuperAdminSuccess' =>
+            'Your superadmin account has been created successfully. Login to start podcasting!',
+        'databaseConnectError' =>
+            'Castopod could not connect to your database. Edit your database configuration and try again.',
+        'writeError' =>
+            "Couldn't create/write the `.env` file. You must create it manually by following the `.env.example` file template in the Castopod package.",
+    ],
+];
diff --git a/modules/Admin/Language/en/MyAccount.php b/modules/Admin/Language/en/MyAccount.php
new file mode 100644
index 0000000000000000000000000000000000000000..68e79e82d9ca18adb73a5664e381b11b07d263ec
--- /dev/null
+++ b/modules/Admin/Language/en/MyAccount.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'info' => 'My account info',
+    'changePassword' => 'Change my password',
+    'messages' => [
+        'wrongPasswordError' => "You've entered the wrong password, try again.",
+        'passwordChangeSuccess' => 'Password has been successfully changed!',
+    ],
+];
diff --git a/modules/Admin/Language/en/Navigation.php b/modules/Admin/Language/en/Navigation.php
new file mode 100644
index 0000000000000000000000000000000000000000..f5ca5aeab3253f21a462d861ec527a2b80847a8f
--- /dev/null
+++ b/modules/Admin/Language/en/Navigation.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'go_to_website' => 'Go to website',
+    'dashboard' => 'Dashboard',
+    'admin' => 'Home',
+    'podcasts' => 'Podcasts',
+    'podcast-list' => 'All podcasts',
+    'podcast-create' => 'New podcast',
+    'podcast-import' => 'Import a podcast',
+    'persons' => 'Persons',
+    'person-list' => 'All persons',
+    'person-create' => 'New person',
+    'fediverse' => 'Fediverse',
+    'fediverse-blocked-actors' => 'Blocked accounts',
+    'fediverse-blocked-domains' => 'Blocked domains',
+    'users' => 'Users',
+    'user-list' => 'All users',
+    'user-create' => 'New user',
+    'pages' => 'Pages',
+    'page-list' => 'All pages',
+    'page-create' => 'New Page',
+    'account' => [
+        'my-account' => 'My account',
+        'change-password' => 'Change password',
+        'logout' => 'Logout',
+    ],
+];
diff --git a/modules/Admin/Language/en/Page.php b/modules/Admin/Language/en/Page.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4a78f89d5d65474acf1f6302e4ada1ac85a56b9
--- /dev/null
+++ b/modules/Admin/Language/en/Page.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'back_to_home' => 'Back to home',
+    'page' => 'Page',
+    'all_pages' => 'All pages',
+    'create' => 'New page',
+    'go_to_page' => 'Go to page',
+    'edit' => 'Edit page',
+    'delete' => 'Delete page',
+    'form' => [
+        'title' => 'Title',
+        'permalink' => 'Permalink',
+        'content' => 'Content',
+        'submit_create' => 'Create page',
+        'submit_edit' => 'Save',
+    ],
+    'messages' => [
+        'createSuccess' => 'The page “{pageTitle}” was created successfully!',
+    ],
+];
diff --git a/modules/Admin/Language/en/Pager.php b/modules/Admin/Language/en/Pager.php
new file mode 100644
index 0000000000000000000000000000000000000000..3b6fb25355873701d1689eac70e8ad1c99262376
--- /dev/null
+++ b/modules/Admin/Language/en/Pager.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'pageNavigation' => 'Page navigation',
+    'first' => 'First',
+    'previous' => 'Previous',
+    'next' => 'Next',
+    'last' => 'Last',
+    'older' => 'Older',
+    'newer' => 'Newer',
+    'invalidTemplate' => '{0} is not a valid Pager template.',
+    'invalidPaginationGroup' => '{0} is not a valid Pagination group.',
+];
diff --git a/modules/Admin/Language/en/Person.php b/modules/Admin/Language/en/Person.php
new file mode 100644
index 0000000000000000000000000000000000000000..7306ed270cc59f5ae1f68646ed115943fbd4868d
--- /dev/null
+++ b/modules/Admin/Language/en/Person.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'persons' => 'Persons',
+    'all_persons' => 'All persons',
+    'no_person' => 'Nobody found!',
+    'create' => 'Create a person',
+    'view' => 'View person',
+    'edit' => 'Edit person',
+    'delete' => 'Delete person',
+    'form' => [
+        'identity_section_title' => 'Identity',
+        'identity_section_subtitle' => 'Who is working on the podcast',
+        'image' => 'Picture',
+        'image_size_hint' =>
+            'Image must be squared with at least 400px wide and tall.',
+        'full_name' => 'Full name',
+        'full_name_hint' => 'This is the full name or alias of the person.',
+        'unique_name' => 'Unique name',
+        'unique_name_hint' => 'Used for URLs',
+        'information_url' => 'Information URL',
+        'information_url_hint' =>
+            'Url to a relevant resource of information about the person, such as a homepage or third-party profile platform.',
+        'submit_create' => 'Create person',
+        'submit_edit' => 'Save person',
+    ],
+    'podcast_form' => [
+        'title' => 'Manage persons',
+        'manage_section_title' => 'Management',
+        'manage_section_subtitle' => 'Remove persons from this podcast',
+        'add_section_title' => 'Add persons to this podcast',
+        'add_section_subtitle' => 'You may pick several persons and roles.',
+        'persons' => 'Persons',
+        'persons_hint' =>
+            'You may select one or several persons with the same roles. You need to create the persons first.',
+        'roles' => 'Roles',
+        'roles_hint' =>
+            'You may select none, one or several roles for a person.',
+        'submit_add' => 'Add person(s)',
+        'remove' => 'Remove',
+    ],
+    'episode_form' => [
+        'title' => 'Manage persons',
+        'manage_section_title' => 'Management',
+        'manage_section_subtitle' => 'Remove persons from this episode',
+        'add_section_title' => 'Add persons to this episode',
+        'add_section_subtitle' => 'You may pick several persons and roles.',
+        'persons' => 'Persons',
+        'persons_hint' =>
+            'You may select one or several persons with the same roles. You need to create the persons first.',
+        'roles' => 'Roles',
+        'roles_hint' =>
+            'You may select none, one or several roles for a person.',
+        'submit_add' => 'Add person(s)',
+        'remove' => 'Remove',
+    ],
+    'credits' => 'Credits',
+];
diff --git a/modules/Admin/Language/en/PersonsTaxonomy.php b/modules/Admin/Language/en/PersonsTaxonomy.php
deleted file mode 100644
index d27704ed302f1a777e135a8f03b5d473b058859f..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/en/PersonsTaxonomy.php
+++ /dev/null
@@ -1,470 +0,0 @@
-<?php
-
-/**
- * @copyright  2021 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://podlibre.org/
- */
-
-/* Autogenerated from https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-en.json on 2021-09-06T09:10:53+00:00 */
-
-return array (
-  'persons' => 
-  array (
-    'creative_direction' => 
-    array (
-      'label' => 'Creative Direction',
-      'roles' => 
-      array (
-        'director' => 
-        array (
-          'label' => 'Director',
-          'description' => 'The Director is the head of the entire creative production, from creative details to logistics. There is typically a single director for a production. This role is primarily seen in fiction podcasts.',
-          'example' => 'Jenna Knorr for "Welcome to Tinsel Town"',
-        ),
-        'assistant_director' => 
-        array (
-          'label' => 'Assistant Director',
-          'description' => 'The Assistant Director is a liaison between the director and the rest of the production, often coordinating the daily logistics of production. There may be multiple assistant directors on a project. This role is primarily seen in fiction podcasts.',
-          'example' => 'William Wright for "Inn Between"',
-        ),
-        'executive_producer' => 
-        array (
-          'label' => 'Executive Producer',
-          'description' => 'The Executive Producer is the lead producer on a production. The role can range in terms of creative control with some "EP"s owning the creative direction of a podcast (in effect taking the role of director), while others may take a more hands off approach. Executive producer may have raised the money to fund the production, but it is not a necessary responsibility of the role.',
-          'example' => 'Jane Rotonda for "The Larry Meiller Show',
-        ),
-        'senior_producer' => 
-        array (
-          'label' => 'Senior Producer',
-          'description' => 'The Senior Producer is the second most senior producer of the production (second to the Executive Producer). They supervise producers and the general direciton and logistics of the entire production.',
-          'example' => 'Dr. Jeremy Weisz from "INspired INsider"',
-        ),
-        'producer' => 
-        array (
-          'label' => 'Producer',
-          'description' => 'The Producer coordinates and executes the production of the podcast. There duties can include helping craft the creative direction of a project, budgeting, research, scheduling, and overseeing editing and final production.',
-          'example' => '',
-        ),
-        'associate_producer' => 
-        array (
-          'label' => 'Associate Producer',
-          'description' => 'The Associate Producer performs one or more producer functions as delegated to them by a Producer.',
-          'example' => 'Alex Baumhardt for "APM Reports"',
-        ),
-        'development_producer' => 
-        array (
-          'label' => 'Development Producer',
-          'description' => 'The Development Producer coordinates and executes the pre-production create direction of a podcast. Their responsibilities include finding new episode and series ideas and working with writers and researchers to prepare the concept for production.',
-          'example' => '',
-        ),
-        'creative_director' => 
-        array (
-          'label' => 'Creative Director',
-          'description' => 'The Creative Director is responsible for the creative strategy and execution of an entire series. Often this role reaches outside of content to affect accompanying artwork, music, marketing campaigns, and more.',
-          'example' => 'Neil Druckmann on "The Official The Last of Us"',
-        ),
-      ),
-    ),
-    'cast' => 
-    array (
-      'label' => 'Cast',
-      'roles' => 
-      array (
-        'host' => 
-        array (
-          'label' => 'Host',
-          'description' => 'The Host is the on-air master of ceremonies of the podcast and a consistent presence on every episode (with the exception of guest hosts and alternative episodes). The Host\'s duties may include conducting interviews, introducing stories and segments, narrating, and more. There may be more than one Host per podcast or episode.',
-          'example' => 'Joe Rogan for "The Joe Rogan Experience"',
-        ),
-        'co_host' => 
-        array (
-          'label' => 'Co-Host',
-          'description' => 'The Co-Host performs many of the same duties as the host, while taking a secondary presence on the podcast.',
-          'example' => 'Dax Shepard for "Armchair Expert"',
-        ),
-        'guest_host' => 
-        array (
-          'label' => 'Guest Host',
-          'description' => 'The Guest Host performs all of the duties of the traditional Host role, but does so in a temporary capacity. Often as a single appearance or a short span of episodes.',
-          'example' => 'Erica Kelly on "Let\'s Taco \'Bout Women and True Crime"',
-        ),
-        'guest' => 
-        array (
-          'label' => 'Guest',
-          'description' => 'The Guest is an outside party who makes an on-air appearance on an episode, often as a participant in a panel or the interview subject.',
-          'example' => 'Lewis Brindley for "Triforce!"',
-        ),
-        'voice_actor' => 
-        array (
-          'label' => 'Voice Actor',
-          'description' => 'The Voice Actor gives a performance in which they lend their voice to the role of a character on a podcast episode. While the majority of voice acting roles will be fictional, the role of voice actor may also cover reenactments of real conversations and people.',
-          'example' => 'Venk Potula for "Masala Jones"',
-        ),
-        'narrator' => 
-        array (
-          'label' => 'Narrator',
-          'description' => 'The Narrator gives a performance in which tell the exposition of a fictional or non-fictional story, often in a scripted manner. The Narrator may also perform voices of characters within the story, provided they still maintain the role of exposition storyteller or "voice of God".',
-          'example' => 'James Harvey Freetly for "Lakeshore & Limbo"',
-        ),
-        'announcer' => 
-        array (
-          'label' => 'Announcer',
-          'description' => 'The Announcer gives short vocal performances for the introduction of the podcast, episode topics, segments, guests, prizes, etc. The Announcer is secondary to the host of the podcast and often performs their introductions in a scripted, produced manner.',
-          'example' => 'Lydia Kapp for "World Builders Anonymous"',
-        ),
-        'reporter' => 
-        array (
-          'label' => 'Reporter',
-          'description' => 'The Reporter finds and investigates news or stories for the podcast, often interviewing subjects and conducting research. The Reporter can be an on-air position as well, as they convey the insights of their investigation.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'writing' => 
-    array (
-      'label' => 'Writing',
-      'roles' => 
-      array (
-        'author' => 
-        array (
-          'label' => 'Author',
-          'description' => 'The Author has written prose or poetry originally intended for text that is now being read verbatim on air.',
-          'example' => 'Heiko Martens for "The Sigmund Freud Files"',
-        ),
-        'editorial_director' => 
-        array (
-          'label' => 'Editorial Director',
-          'description' => 'The Editorial Director heads all departments of the organization behind the podcast and is held accountable for delegating tasks to staff members and managing them. They are the highest-ranking editor and are responsible for the direction, accuracy, and decisions behind podcast content.',
-          'example' => 'Christopher Twarowski for "News Beat"',
-        ),
-        'co_writer' => 
-        array (
-          'label' => 'Co-Writer',
-          'description' => 'The Co-Writer has written a podcast in partnership with 1-2 other writers, sharing credit together for the creative arc, dialogue, and narration.',
-          'example' => 'Max Eggers on "THE LIGHTHOUSE"',
-        ),
-        'writer' => 
-        array (
-          'label' => 'Writer',
-          'description' => 'The Writer has written the story or dialogue of a podcast. The Writer is often involved in the creative arc of a production, but this is not a necessary requirement. Writers may work in fictional or non-fictional podcasts.',
-          'example' => '',
-        ),
-        'songwriter' => 
-        array (
-          'label' => 'Songwriter',
-          'description' => 'The Songwriter has written the lyrics and/or accompanying music to an original song created for the podcast and played on an episode.',
-          'example' => 'Ben Lapidus for "Gay Future"',
-        ),
-        'guest_writer' => 
-        array (
-          'label' => 'Guest Writer',
-          'description' => 'The Guest Writer performs the duties of a writer in a temporary capacity, often as a single episode or a short span of episodes. The distinction between writer and Guest Writer depends on the decision of the podcast itself.',
-          'example' => 'Beth Crane for "The Unseen Hour"',
-        ),
-        'story_editor' => 
-        array (
-          'label' => 'Story Editor',
-          'description' => 'The Story Editor is responsible for broad stroke direction of the story arc and character development of a podcast. Often seen in fiction and documentary podcasts.',
-          'example' => 'Gabrielle Loux for "The NoSleep Podcast"',
-        ),
-        'managing_editor' => 
-        array (
-          'label' => 'Managing Editor',
-          'description' => 'The Managing Editor oversees and coordinates the podcasts editorial activities, providing both detailed editing and managing a staff of writers and editors to ensure proper deadlines and budgets are being met.',
-          'example' => 'Flora Lichtman for "Every Little Thing"',
-        ),
-        'script_editor' => 
-        array (
-          'label' => 'Script Editor',
-          'description' => 'The Script Editor provides notes and editing to the recording script in a very "hands on" role. The Script Editor is primarily used in fiction, documentary, and advertisements where scripted recordings are prevalent.',
-          'example' => 'Alex Rioux for "Welcome to Tinsel Town: A Christmas Adventure"',
-        ),
-        'script_coordinator' => 
-        array (
-          'label' => 'Script Coordinator',
-          'description' => 'The Script Coordinator packages the final script with annotations that reflect specific logistics and creative cues for recording and production.',
-          'example' => 'Alex Rioux for "Welcome to Tinsel Town: A Christmas Adventure"',
-        ),
-        'researcher' => 
-        array (
-          'label' => 'Researcher',
-          'description' => 'The Researcher coordinates the sourcing and verification of information that can then be used for the content of a podcast episode, often informing the direction of a story based on new insights uncovered.',
-          'example' => 'Dave Grave for "The Zero Brain Podcast"',
-        ),
-        'editor' => 
-        array (
-          'label' => 'Editor',
-          'description' => 'The Editor reviews and prepares scripts for conveying information in a creative, accurate, and engaging manner.',
-          'example' => '',
-        ),
-        'fact_checker' => 
-        array (
-          'label' => 'Fact Checker',
-          'description' => 'The Fact Checker reviews the content of a podcast for factual correctness and verifies that quote attribution is correct. They use a variety of tools including 3rd party research and individual outreach. Often the Fact Checker will also provide notes on how the production can avoid the confusion in the delivery of information in the episode.',
-          'example' => '',
-        ),
-        'translator' => 
-        array (
-          'label' => 'Translator',
-          'description' => 'The Translator converts content from one language to another for the podcast. This can be interviews, dialogue, text documents, and more. The Translator\'s work may be used on-air or behind-the-scenes during the production/research process.',
-          'example' => '',
-        ),
-        'transcriber' => 
-        array (
-          'label' => 'Transcriber',
-          'description' => 'The Transcriber turns dialogue and audio cues into text, which can be used internally for production processes or displayed publicly for listeners.',
-          'example' => '',
-        ),
-        'logger' => 
-        array (
-          'label' => 'Logger',
-          'description' => 'The Logger reviews and documents the contents and timestamps of raw audio in service of producers and editors in the production process.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'audio_production' => 
-    array (
-      'label' => 'Audio Production',
-      'roles' => 
-      array (
-        'studio_coordinator' => 
-        array (
-          'label' => 'Studio Coordinator',
-          'description' => 'The Studio Coordinator manages the recording studio and audio technicians working within the studio at the time of recording.',
-          'example' => '',
-        ),
-        'technical_director' => 
-        array (
-          'label' => 'Technical Director',
-          'description' => 'The Technical Director oversees the podcast\'s recording and production as it is involved with audio technologies including hardware and software, and managing roles involved these areas.',
-          'example' => 'Adam Raymonda on "Celebuzz\'d"',
-        ),
-        'technical_manager' => 
-        array (
-          'label' => 'Technical Manager',
-          'description' => 'The Technical Manager coordinates a team of audio engineers and studio staff, in the recording and production as it is involved with audio technologies including hardware and software.',
-          'example' => '',
-        ),
-        'audio_engineer' => 
-        array (
-          'label' => 'Audio Engineer',
-          'description' => 'The Audio Engineer helps record and produce audio by setting up recording environments, monitoring recoding, and providing technical adjustments throughout. The Audio Engineer is present during the recording process, most often making adjustments in real time. The Audio Engineer may work with conversation, music, foley, or any other type of audio.',
-          'example' => 'Peter Leonard from "Startup Podcast"',
-        ),
-        'remote_recording_engineer' => 
-        array (
-          'label' => 'Remote Recording Engineer',
-          'description' => 'The Remote Recording Engineer ensures the proper recording of conversations taking place in multiple locations across a phone line or internet connection. The Remote Recording Engineer evaluates the different recording set ups and attempts to reconcile them into a cohesive sound, while also monitoring the recording process to capture the best possible audio.',
-          'example' => '',
-        ),
-        'post_production_engineer' => 
-        array (
-          'label' => 'Post Production Engineer',
-          'description' => 'The Post Production Engineer evaluates audio technologies and their application as it pertains to the final steps of production and publication.',
-          'example' => 'Dick Wound for "Queens Next Door"',
-        ),
-      ),
-    ),
-    'audio_post_production' => 
-    array (
-      'label' => 'Audio Post-Production',
-      'roles' => 
-      array (
-        'audio_editor' => 
-        array (
-          'label' => 'Audio Editor',
-          'description' => 'The Audio Editor cuts and rearranges audio for clarity and storytelling purposes. The Audio Editor may also perform general audio processing and mastering.',
-          'example' => '',
-        ),
-        'sound_designer' => 
-        array (
-          'label' => 'Sound Designer',
-          'description' => 'The Sound Designer creates and composes a variety of audio elements. These elements are mostly secondary to speech, but a Sound Designer may creatively edit/produce speech elements in an artist manner.',
-          'example' => '',
-        ),
-        'foley_artist' => 
-        array (
-          'label' => 'Foley Artist',
-          'description' => 'The Foley Artist sound effects for a podcast and can do so both via physical recording and digital processing, or a combination of the two.',
-          'example' => '',
-        ),
-        'composer' => 
-        array (
-          'label' => 'Composer',
-          'description' => 'The Composer writes an original musical piece (or multiple) that is played on the published episode. The Composer will also often be the performer of said musical piece.',
-          'example' => 'Marcus Thorne Bagala from "This American Life"',
-        ),
-        'theme_music' => 
-        array (
-          'label' => 'Theme Music',
-          'description' => 'Theme Music is a musical piece that accompanies the podcast across multiple episodes, most often at the beginning of an episode. The Theme Music is used to introduce the podcast as a brand. This role is for the creator of the theme music.',
-          'example' => 'Mark Philips from "Startup Podcast"',
-        ),
-        'music_production' => 
-        array (
-          'label' => 'Music Production',
-          'description' => 'The Music Production role helps creatively craft music in a role separate from the writing of said music. Music Production often involves creative decisions per the method in which music is recorded, the arrangement of instruments, the use of effects, and more.',
-          'example' => 'Storm Duper for "Faking Star Wars Radio"',
-        ),
-        'music_contributor' => 
-        array (
-          'label' => 'Music Contributor',
-          'description' => 'The Music Contributor is the creator of music that was used for the podcast but not necessarily produced specifically for the podcast. Often a podcast will use an existing musical piece and credit the original creator.',
-          'example' => 'Bobby Lord from "Startup Podcast"',
-        ),
-      ),
-    ),
-    'administration' => 
-    array (
-      'label' => 'Administration',
-      'roles' => 
-      array (
-        'production_coordinator' => 
-        array (
-          'label' => 'Production Coordinator',
-          'description' => 'The Production Coordinator is responsible for managing the logistics of the production process from recording to publication, including attaining the required permissions and permits, connecting the various production and recording teams, coordinating the creation of post-production metadata, budgeting, and more.',
-          'example' => 'Taneya Boyde on "Ready For Change?"',
-        ),
-        'booking_coordinator' => 
-        array (
-          'label' => 'Booking Coordinator',
-          'description' => 'The Booking Coordinator is responsible for bringing on new guests for interviews, including sourcing guests, scheduling interviews, onboarding materials, and post-publication processes.',
-          'example' => 'Meryl Klemow for "Campfire Sht Show"',
-        ),
-        'production_assistant' => 
-        array (
-          'label' => 'Production Assistant',
-          'description' => 'The Production Assistant helps support an executive member of a podcast (often a director or producer), helping prepare them in a variety of ways including scheduling, logistics, communications, and more.',
-          'example' => 'Wallace Mack for "The Nod"',
-        ),
-        'content_manager' => 
-        array (
-          'label' => 'Content Manager',
-          'description' => 'The Content Manager is responsible for the distribution of a podcast\'s content within and outside of episode, including but not limited to clips, newsletters, images, cross-promotions, and more.',
-          'example' => 'Kenneth Lee Johnson II for "Malice Corp Smack Talk"',
-        ),
-        'marketing_manager' => 
-        array (
-          'label' => 'Marketing Manager',
-          'description' => 'The Marketing Manager is responsibile for the promotion of a podcast\'s content through various awareness strategies such as social media campaigns, cultivating a web presence, managing public relations and communications strategies, and other creative techniques to acquire and retain listeners.',
-          'example' => '',
-        ),
-        'sales_representative' => 
-        array (
-          'label' => 'Sales Representative',
-          'description' => 'The Sales Representative is responsible for monetization of podcast content through managing and selling advertising inventory.',
-          'example' => '',
-        ),
-        'sales_manager' => 
-        array (
-          'label' => 'Sales Manager',
-          'description' => 'The Sales Manager is responsible for all aspects of podcast monetization such as overseeing Sales Representatives, managing advertising inventory, and devising monetization strategies through channels such as affiliate partnerships, merchandise, live events, and other revenue strategies.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'visuals' => 
-    array (
-      'label' => 'Visuals',
-      'roles' => 
-      array (
-        'graphic_designer' => 
-        array (
-          'label' => 'Graphic Designer',
-          'description' => 'The Graphic Designer is someone who has created any custom visuals to accompany the podcast in a variety of ways.',
-          'example' => 'Sky Knight for "The XP Billionaires"',
-        ),
-        'cover_art_designer' => 
-        array (
-          'label' => 'Cover Art Designer',
-          'description' => 'The Cover Art Designer creates the displayed cover art of a podcast or episode. For clarity, cover art is the main image (almost always square in dimensions) accompanying the podcast in directories, while episode cover art is displayed in a similar manner at the episode level. This role may be a digital designer, artist, photographer or any other visual creative.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'community' => 
-    array (
-      'label' => 'Community',
-      'roles' => 
-      array (
-        'social_media_manager' => 
-        array (
-          'label' => 'Social Media Manager',
-          'description' => 'The Social Media Manager runs the social media accounts of the podcast, including but not limited to the creation of content, posting, replies, monitoring, and more.',
-          'example' => 'Tom Joshi-Cale for "World on a String"',
-        ),
-      ),
-    ),
-    'misc' => 
-    array (
-      'label' => 'Misc.',
-      'roles' => 
-      array (
-        'consultant' => 
-        array (
-          'label' => 'Consultant',
-          'description' => 'A Consultant is a third-party position where someone from outside the organization works on a project, often offering a specific expertise. This is a modifier role and can be applied to any work area.',
-          'example' => 'Ross Wilcock for "Being Kenzie-Feature Length Immersive Horror"',
-        ),
-        'intern' => 
-        array (
-          'label' => 'Intern',
-          'description' => 'An Intern is an apprentice position where someone works for a limited time within an organization to gain work experience in a specific field. This is a modifier role and can be applied to any work area.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'video_production' => 
-    array (
-      'label' => 'Video Production',
-      'roles' => 
-      array (
-        'camera_operator' => 
-        array (
-          'label' => 'Camera Operator',
-          'description' => 'A camera operator is responsible for capturing and recording all aspects of a scene for film and television. They must understand the technicalities of how to operate a camera, frame a proper shot with respect to lighting and staging, focus the lens and have a visual eye to achieve a specific look.',
-          'example' => '',
-        ),
-        'lighting_designer' => 
-        array (
-          'label' => 'Lighting Designer',
-          'description' => 'A lighting designer works with the DP and Director to craft a specific look and feel of a scene utilizing various lighting techniques. They must be able to interpret the creative direction and bring it to life.',
-          'example' => '',
-        ),
-        'camera_grip' => 
-        array (
-          'label' => 'Camera Grip',
-          'description' => 'A camera grip is responsible for building and maintaining all the parts of a camera and its accessories such as the tripods, cranes, dollies, etc.',
-          'example' => '',
-        ),
-        'assistant_camera' => 
-        array (
-          'label' => 'Assistant Camera',
-          'description' => '1st AC is responsible for the camera equipment, building the cameras before the start of each day, organizing all the parts and various accessories, swapping out lenses when necessary and also pulls focus for the DP and camera operators. The AC will also wrap out each day by cleaning the cameras, writing camera notes, marking the media cards, and delivering them to the DIT.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'video_post_production' => 
-    array (
-      'label' => 'Video Post-Production',
-      'roles' => 
-      array (
-        'editor' => 
-        array (
-          'label' => 'Editor',
-          'description' => 'Television editors are responsible for taking the shot footage and clips and blending them together to craft the director\'s vision and storytelling.',
-          'example' => '',
-        ),
-        'assistant_editor' => 
-        array (
-          'label' => 'Assistant Editor',
-          'description' => 'The Assistant Editor is responsible for taking the media from the set, ingesting them into the designated editing software, and organizing the footage in an efficient way for the editor. They must also pay close attention to ensure that audio and video are synced and that all footage from set is ingested properly.',
-          'example' => '',
-        ),
-      ),
-    ),
-  ),
-);
diff --git a/modules/Admin/Language/en/Platforms.php b/modules/Admin/Language/en/Platforms.php
new file mode 100644
index 0000000000000000000000000000000000000000..27582cdad8bb32477dbdaef513f513f678bf1485
--- /dev/null
+++ b/modules/Admin/Language/en/Platforms.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'title' => 'Platforms',
+    'home_url' => 'Go to {platformName} website',
+    'submit_url' => 'Submit your podcast on {platformName}',
+    'visible' => 'Display in podcast homepage?',
+    'on_embeddable_player' => 'Display on embeddable player?',
+    'remove' => 'Remove {platformName}',
+    'submit' => 'Save',
+    'messages' => [
+        'updateSuccess' => 'Platform links have been successfully updated!',
+        'removeLinkSuccess' => 'The platform link has been removed.',
+        'removeLinkError' =>
+            'The platform link could not be removed. Try again.',
+    ],
+    'description' => [
+        'podcasting' => 'The podcast ID on this platform',
+        'social' => 'The podcast account ID on this platform',
+        'funding' => 'Call to action message',
+    ],
+];
diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php
new file mode 100644
index 0000000000000000000000000000000000000000..946b0b1be436b3db30532ec98e148056ddd11c73
--- /dev/null
+++ b/modules/Admin/Language/en/Podcast.php
@@ -0,0 +1,237 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'all_podcasts' => 'All podcasts',
+    'no_podcast' => 'No podcast found!',
+    'create' => 'Create a podcast',
+    'import' => 'Import a podcast',
+    'new_episode' => 'New Episode',
+    'feed' => 'RSS',
+    'view' => 'View podcast',
+    'edit' => 'Edit podcast',
+    'delete' => 'Delete podcast',
+    'see_episodes' => 'See episodes',
+    'see_contributors' => 'See contributors',
+    'go_to_page' => 'Go to page',
+    'latest_episodes' => 'Latest episodes',
+    'see_all_episodes' => 'See all episodes',
+    'form' => [
+        'identity_section_title' => 'Podcast identity',
+        'identity_section_subtitle' => 'These fields allow you to get noticed.',
+        'image' => 'Cover image',
+        'title' => 'Title',
+        'handle' => 'Handle',
+        'handle_hint' =>
+            'Used to identify the podcast. Uppercase, lowercase, numbers and underscores are accepted.',
+        'type' => [
+            'label' => 'Type',
+            'hint' =>
+                '- <strong>episodic</strong>: if episodes are intended to be consumed without any specific order. Newest episodes will be presented first.<br/>- <strong>serial</strong>: if episodes are intended to be consumed in sequential order. The oldest episodes will be presented first.',
+            'episodic' => 'Episodic',
+            'serial' => 'Serial',
+        ],
+        'description' => 'Description',
+        'classification_section_title' => 'Classification',
+        'classification_section_subtitle' =>
+            'These fields will impact your audience and competition.',
+        'language' => 'Language',
+        'category' => 'Category',
+        'category_placeholder' => 'Select a category…',
+        'other_categories' => 'Other categories',
+        'parental_advisory' => [
+            'label' => 'Parental advisory',
+            'hint' => 'Does it contain explicit content?',
+            'undefined' => 'undefined',
+            'clean' => 'Clean',
+            'explicit' => 'Explicit',
+        ],
+        'author_section_title' => 'Author',
+        'author_section_subtitle' => 'Who is managing the podcast?',
+        'owner_name' => 'Owner name',
+        'owner_name_hint' =>
+            'For administrative use only. Visible in the public RSS feed.',
+        'owner_email' => 'Owner email',
+        'owner_email_hint' =>
+            'Will be used by most platforms to verify the podcast ownership. Visible in the public RSS feed.',
+        'publisher' => 'Publisher',
+        'publisher_hint' =>
+            'The group responsible for creating the show. Often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.',
+        'copyright' => 'Copyright',
+        'location_section_title' => 'Location',
+        'location_section_subtitle' => 'What place is this podcast about?',
+        'location_name' => 'Location name or address',
+        'location_name_hint' => 'This can be a real place or fictional',
+        'monetization_section_title' => 'Monetization',
+        'monetization_section_subtitle' =>
+            'Earn money thanks to your audience.',
+        'payment_pointer' => 'Payment Pointer for Web Monetization',
+        'payment_pointer_hint' =>
+            'This is your where you will receive money thanks to Web Monetization',
+        'advanced_section_title' => 'Advanced Parameters',
+        'advanced_section_subtitle' =>
+            'If you need RSS tags that Castopod does not handle, set them here.',
+        'custom_rss' => 'Custom RSS tags for the podcast',
+        'custom_rss_hint' => 'This will be injected within the ❬channel❭ tag.',
+        'partnership' => 'Partnership',
+        'partner_id' => 'ID',
+        'partner_link_url' => 'Link URL',
+        'partner_image_url' => 'Image URL',
+        'partner_id_hint' => 'Your own partner ID',
+        'partner_link_url_hint' => 'The generic partner link address',
+        'partner_image_url_hint' => 'The generic partner image address',
+        'status_section_title' => 'Status',
+        'status_section_subtitle' => 'Dead or alive?',
+        'block' => 'Podcast should be hidden from all platforms',
+        'complete' => 'Podcast will not be having new episodes',
+        'lock' => 'Prevent podcast from being copied',
+        'lock_hint' =>
+            'The purpose is to tell other podcast platforms whether they are allowed to import this feed. A value of yes means that any attempt to import this feed into a new platform should be rejected.',
+        'submit_create' => 'Create podcast',
+        'submit_edit' => 'Save podcast',
+    ],
+    'category_options' => [
+        'uncategorized' => 'uncategorized',
+        'arts' => 'Arts',
+        'business' => 'Business',
+        'comedy' => 'Comedy',
+        'education' => 'Education',
+        'fiction' => 'Fiction',
+        'government' => 'Government',
+        'health_and_fitness' => 'Health &amp Fitness',
+        'history' => 'History',
+        'kids_and_family' => 'Kids &amp Family',
+        'leisure' => 'Leisure',
+        'music' => 'Music',
+        'news' => 'News',
+        'religion_and_spirituality' => 'Religion &amp Spirituality',
+        'science' => 'Science',
+        'society_and_culture' => 'Society &amp Culture',
+        'sports' => 'Sports',
+        'technology' => 'Technology',
+        'true_crime' => 'True Crime',
+        'tv_and_film' => 'TV &amp Film',
+        'books' => 'Books',
+        'design' => 'Design',
+        'fashion_and_beauty' => 'Fashion &amp Beauty',
+        'food' => 'Food',
+        'performing_arts' => 'Performing Arts',
+        'visual_arts' => 'Visual Arts',
+        'careers' => 'Careers',
+        'entrepreneurship' => 'Entrepreneurship',
+        'investing' => 'Investing',
+        'management' => 'Management',
+        'marketing' => 'Marketing',
+        'non_profit' => 'Non-Profit',
+        'comedy_interviews' => 'Comedy Interviews',
+        'improv' => 'Improv',
+        'stand_up' => 'Stand-Up',
+        'courses' => 'Courses',
+        'how_to' => 'How To',
+        'language_learning' => 'Language Learning',
+        'self_improvement' => 'Self-Improvement',
+        'comedy_fiction' => 'Comedy Fiction',
+        'drama' => 'Drama',
+        'science_fiction' => 'Science Fiction',
+        'alternative_health' => 'Alternative Health',
+        'fitness' => 'Fitness',
+        'medicine' => 'Medicine',
+        'mental_health' => 'Mental Health',
+        'nutrition' => 'Nutrition',
+        'sexuality' => 'Sexuality',
+        'education_for_kids' => 'Education for Kids',
+        'parenting' => 'Parenting',
+        'pets_and_animals' => 'Pets &amp Animals',
+        'stories_for_kids' => 'Stories for Kids',
+        'animation_and_manga' => 'Animation &amp Manga',
+        'automotive' => 'Automotive',
+        'aviation' => 'Aviation',
+        'crafts' => 'Crafts',
+        'games' => 'Games',
+        'hobbies' => 'Hobbies',
+        'home_and_garden' => 'Home &amp Garden',
+        'video_games' => 'Video Games',
+        'music_commentary' => 'Music Commentary',
+        'music_history' => 'Music History',
+        'music_interviews' => 'Music Interviews',
+        'business_news' => 'Business News',
+        'daily_news' => 'Daily News',
+        'entertainment_news' => 'Entertainment News',
+        'news_commentary' => 'News Commentary',
+        'politics' => 'Politics',
+        'sports_news' => 'Sports News',
+        'tech_news' => 'Tech News',
+        'buddhism' => 'Buddhism',
+        'christianity' => 'Christianity',
+        'hinduism' => 'Hinduism',
+        'islam' => 'Islam',
+        'judaism' => 'Judaism',
+        'religion' => 'Religion',
+        'spirituality' => 'Spirituality',
+        'astronomy' => 'Astronomy',
+        'chemistry' => 'Chemistry',
+        'earth_sciences' => 'Earth Sciences',
+        'life_sciences' => 'Life Sciences',
+        'mathematics' => 'Mathematics',
+        'natural_sciences' => 'Natural Sciences',
+        'nature' => 'Nature',
+        'physics' => 'Physics',
+        'social_sciences' => 'Social Sciences',
+        'documentary' => 'Documentary',
+        'personal_journals' => 'Personal Journals',
+        'philosophy' => 'Philosophy',
+        'places_and_travel' => 'Places &amp Travel',
+        'relationships' => 'Relationships',
+        'baseball' => 'Baseball',
+        'basketball' => 'Basketball',
+        'cricket' => 'Cricket',
+        'fantasy_sports' => 'Fantasy Sports',
+        'football' => 'Football',
+        'golf' => 'Golf',
+        'hockey' => 'Hockey',
+        'rugby' => 'Rugby',
+        'running' => 'Running',
+        'soccer' => 'Soccer',
+        'swimming' => 'Swimming',
+        'tennis' => 'Tennis',
+        'volleyball' => 'Volleyball',
+        'wilderness' => 'Wilderness',
+        'wrestling' => 'Wrestling',
+        'after_shows' => 'After Shows',
+        'film_history' => 'Film History',
+        'film_interviews' => 'Film Interviews',
+        'film_reviews' => 'Film Reviews',
+        'tv_reviews' => 'TV Reviews',
+    ],
+    'by' => 'By {publisher}',
+    'season' => 'Season {seasonNumber}',
+    'list_of_episodes_year' => '{year} episodes ({episodeCount})',
+    'list_of_episodes_season' =>
+        'Season {seasonNumber} episodes ({episodeCount})',
+    'no_episode' => 'No episode found!',
+    'no_episode_hint' =>
+        'Navigate the podcast episodes with the navigation bar above.',
+    'follow' => 'Follow',
+    'followers' => '{numberOfFollowers, plural,
+        one {<span class="font-semibold">#</span> follower}
+        other {<span class="font-semibold">#</span> followers}
+    }',
+    'posts' => '{numberOfPosts, plural,
+        one {<span class="font-semibold">#</span> post}
+        other {<span class="font-semibold">#</span> posts}
+    }',
+    'activity' => 'Activity',
+    'episodes' => 'Episodes',
+    'sponsor_title' => 'Enjoying the show?',
+    'sponsor' => 'Sponsor',
+    'funding_links' => 'Funding links for {podcastTitle}',
+    'find_on' => 'Find {podcastTitle} on',
+    'listen_on' => 'Listen on',
+];
diff --git a/modules/Admin/Language/en/PodcastImport.php b/modules/Admin/Language/en/PodcastImport.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6e8774c2a7650462eafe8d3e914adb0746be641
--- /dev/null
+++ b/modules/Admin/Language/en/PodcastImport.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'warning' =>
+        'This procedure may take a long time.<br/>As the current version does not show any progress while it runs, you will not see anything updated until it is done.<br/>In case of timeout error, increase `max_execution_time` value.',
+    'old_podcast_section_title' => 'The podcast to import',
+    'old_podcast_section_subtitle' =>
+        'Make sure you own the rights for this podcast before importing it. Copying and broadcasting a podcast without the proper rights is piracy and is liable to prosecution.',
+    'imported_feed_url' => 'Feed URL',
+    'imported_feed_url_hint' => 'The feed must be in xml or rss format.',
+    'new_podcast_section_title' => 'The new podcast',
+    'advanced_params_section_title' => 'Advanced parameters',
+    'advanced_params_section_subtitle' =>
+        'Keep the default values if you have no idea of what the fields are for.',
+    'slug_field' => [
+        'label' => 'Which field should be used to calculate episode slug',
+        'link' => '&lt;link&gt;',
+        'title' => '&lt;title&gt;',
+    ],
+    'description_field' =>
+        'Source field used for episode description / show notes',
+    'force_renumber' => 'Force episodes renumbering',
+    'force_renumber_hint' =>
+        'Use this if your podcast does not have episode numbers but wish to set them during import.',
+    'season_number' => 'Season number',
+    'season_number_hint' =>
+        'Use this if your podcast does not have a season number but wish to set one during import. Leave blank otherwise.',
+    'max_episodes' => 'Maximum number of episodes to import',
+    'max_episodes_hint' => 'Leave blank to import all episodes',
+    'lock_import' =>
+        'This feed is protected. You cannot import it. If you are the owner, unprotect it on the origin platform.',
+    'submit' => 'Import podcast',
+];
diff --git a/modules/Admin/Language/en/PodcastNavigation.php b/modules/Admin/Language/en/PodcastNavigation.php
new file mode 100644
index 0000000000000000000000000000000000000000..147eebb6a654d2b3d1188282c77149dd85ed2c8f
--- /dev/null
+++ b/modules/Admin/Language/en/PodcastNavigation.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'go_to_page' => 'Go to podcast page',
+    'dashboard' => 'Podcast dashboard',
+    'podcast-view' => 'Home',
+    'podcast-edit' => 'Edit podcast',
+    'episodes' => 'Episodes',
+    'episode-list' => 'All episodes',
+    'episode-create' => 'New episode',
+    'fediverse' => 'Fediverse',
+    'fediverse-block_lists' => 'Block lists',
+    'analytics' => 'Analytics',
+    'persons' => 'Persons',
+    'podcast-person-manage' => 'Manage persons',
+    'contributors' => 'Contributors',
+    'contributor-list' => 'All contributors',
+    'contributor-add' => 'Add contributor',
+    'platforms' => 'External platforms',
+    'platforms-podcasting' => 'Podcasting',
+    'platforms-social' => 'Social Networks',
+    'platforms-funding' => 'Funding',
+    'podcast-analytics' => 'Audience overview',
+    'podcast-analytics-webpages' => 'Web pages visits',
+    'podcast-analytics-locations' => 'Locations',
+    'podcast-analytics-unique-listeners' => 'Unique listeners',
+    'podcast-analytics-players' => 'Players',
+    'podcast-analytics-listening-time' => 'Listening time',
+    'podcast-analytics-time-periods' => 'Time periods',
+];
diff --git a/modules/Admin/Language/en/User.php b/modules/Admin/Language/en/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..97cfe2f73cb726e713c2e7b8a26c21b089cd64e9
--- /dev/null
+++ b/modules/Admin/Language/en/User.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'forcePassReset' => 'Force pass reset',
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'rolesEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'forcePassResetSuccess' =>
+            '{username} will be prompted with a password reset upon next visit.',
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/en/Validation.php b/modules/Admin/Language/en/Validation.php
new file mode 100644
index 0000000000000000000000000000000000000000..ae627c4b2122b0f3877da54c87c2ca7c0b5c10ba
--- /dev/null
+++ b/modules/Admin/Language/en/Validation.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'min_dims' =>
+        '{field} is either not an image, or it is not wide or tall enough.',
+    'is_image_squared' =>
+        '{field} is either not an image, or it is not squared (width and height differ).',
+    'validate_url' =>
+        'The {field} field must be a valid URL (eg. https://example.com/).',
+];
diff --git a/modules/Admin/Language/fr/Admin.php b/modules/Admin/Language/fr/Admin.php
new file mode 100644
index 0000000000000000000000000000000000000000..5c0d82e17b2545ded5a38673e24454355a4f8b29
--- /dev/null
+++ b/modules/Admin/Language/fr/Admin.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'dashboard' => 'Tableau de bord',
+    'welcome_message' => 'Bienvenue dans l’administration !',
+];
diff --git a/modules/Admin/Language/fr/Breadcrumb.php b/modules/Admin/Language/fr/Breadcrumb.php
new file mode 100644
index 0000000000000000000000000000000000000000..08d8d888ac87bf879415fe1537662a59ca4b96ac
--- /dev/null
+++ b/modules/Admin/Language/fr/Breadcrumb.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'label' => 'Fil d’Ariane',
+    config('Admin')
+        ->gateway => 'Accueil',
+    'podcasts' => 'podcasts',
+    'episodes' => 'épisodes',
+    'contributors' => 'contributeurs',
+    'pages' => 'pages',
+    'add' => 'ajouter',
+    'new' => 'créer',
+    'edit' => 'modifier',
+    'persons' => 'intervenants',
+    'publish' => 'publier',
+    'publish-edit' => 'modifier la publication',
+    'unpublish' => 'dépublier',
+    'fediverse' => 'fédiverse',
+    'block-lists' => 'listes de blocage',
+    'users' => 'utilisateurs',
+    'my-account' => 'mon compte',
+    'change-password' => 'changer le mot de passe',
+    'import' => 'importer un flux',
+    'platforms' => 'plateformes',
+    'social' => 'réseaux sociaux',
+    'funding' => 'financement',
+    'analytics' => 'mesures d’audience',
+    'locations' => 'localisations',
+    'webpages' => 'pages web',
+    'unique-listeners' => 'auditeurs uniques',
+    'players' => 'lecteurs',
+    'listening-time' => 'drée d’écoute',
+    'time-periods' => 'périodes',
+    'soundbites' => 'extraits sonores',
+    'embeddable-player' => 'lecteur intégré',
+];
diff --git a/modules/Admin/Language/fr/Charts.php b/modules/Admin/Language/fr/Charts.php
new file mode 100644
index 0000000000000000000000000000000000000000..071918ae78042cd672826bc61fb1fc38d302184d
--- /dev/null
+++ b/modules/Admin/Language/fr/Charts.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'by_service_weekly' =>
+        'Téléchargements d’épisodes par service (sur la dernière semaine)',
+    'by_player_weekly' =>
+        'Téléchargements d’épisodes par lecteur (sur la dernière semaine)',
+    'by_player_yearly' =>
+        'Téléchargements d’épisodes par lecteur (sur la dernière année)',
+    'by_device_weekly' =>
+        'Téléchargements d’épisodes par appareil (sur la dernière semaine)',
+    'by_os_weekly' =>
+        'Téléchargements d’épisodes par OS (sur la dernière semaine)',
+    'podcast_by_region' =>
+        'Téléchargements d’épisodes par région (sur la dernière semaine)',
+    'unique_daily_listeners' => 'Auditeurs uniques quotidiens',
+    'unique_monthly_listeners' => 'Auditeurs uniques mensuels',
+    'by_browser' =>
+        'Fréquentation des pages web par navigateur (sur la dernière semaine)',
+    'podcast_by_day' => 'Téléchargements quotidiens d’épisodes',
+    'podcast_by_month' => 'Téléchargements mensuels d’épisodes',
+    'episode_by_day' =>
+        'Téléchargements quotidiens de l’épisode (sur les 60 premiers jours)',
+    'episode_by_month' => 'Téléchargements mensuels de l’épisode',
+    'episodes_by_day' =>
+        'Téléchargements des 5 derniers épisodes (sur les 60 premiers jours)',
+    'by_country_weekly' =>
+        'Téléchargement d’épisodes par pays (sur la dernière semaine)',
+    'by_country_yearly' =>
+        'Téléchargement d’épisodes par pays (sur la dernière année)',
+    'by_domain_weekly' =>
+        'Fréquentation des pages web par origine (sur la dernière semaine)',
+    'by_domain_yearly' =>
+        'Fréquentation des pages web par origine (sur la dernière année)',
+    'by_entry_page' =>
+        'Fréquentation des pages web par page d’entrée (sur la dernière semaine)',
+    'podcast_bots' => 'Robots (bots)',
+    'daily_listening_time' => 'Durée quotidienne d’écoute cumulée',
+    'monthly_listening_time' => 'Durée mensuelle d’écoute cumulée',
+    'by_weekday' => 'Par jour de la semaine (sur les 60 derniers jours)',
+    'by_hour' => 'Par heure de la journée (sur les 60 derniers jours)',
+    'podcast_by_bandwidth' => 'Bande passante quotidienne consommée (en Mo)',
+];
diff --git a/modules/Admin/Language/fr/Common.php b/modules/Admin/Language/fr/Common.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ab2171c708e377953d98f7ab6ac0bb284bfb98c
--- /dev/null
+++ b/modules/Admin/Language/fr/Common.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'yes' => 'Oui',
+    'no' => 'Non',
+    'cancel' => 'Annuler',
+    'optional' => 'Optionnel',
+    'more' => 'Plus',
+    'no_data' => 'Aucune donnée trouvée !',
+    'close' => 'Fermer',
+    'edit' => 'Modifier',
+    'copy' => 'Copier',
+    'copied' => 'Copié !',
+    'home' => 'Accueil',
+    'explicit' => 'Explicite',
+    'mediumDate' => '{0,date,medium}',
+    'powered_by' => 'Propulsé par {castopod}.',
+    'actions' => 'Actions',
+    'pageInfo' => 'Page {currentPage} sur {pageCount}',
+    'go_back' => 'Retour en arrière',
+    'forms' => [
+        'editor' => [
+            'write' => 'Écrire',
+            'preview' => 'Aperçu',
+            'help' => 'Propulsé par markdown',
+        ],
+        'multiSelect' => [
+            'selectText' => 'Cliquez pour selectionner',
+            'loadingText' => 'Chargement...',
+            'noResultsText' => 'Aucun résultat trouvé',
+            'noChoicesText' => 'Aucune sélection possible',
+            'maxItemText' => 'Impossible de rajouter un élément',
+        ],
+        'image_size_hint' =>
+            'L’image doit être carrée, avec au minimum 1400px de long et de large.',
+        'upload_file' => 'Téléversez un fichier',
+        'remote_url' => 'URL distante',
+    ],
+    'play_episode_button' => [
+        'play' => 'Lire',
+        'playing' => 'En cours',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Contributor.php b/modules/Admin/Language/fr/Contributor.php
new file mode 100644
index 0000000000000000000000000000000000000000..7e62309e4c9fb0a8e4fe73c485f063a0cbf4a302
--- /dev/null
+++ b/modules/Admin/Language/fr/Contributor.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'podcast_contributors' => 'Contributeurs du podcast',
+    'view' => 'Contribution de {username} à {podcastTitle}',
+    'add' => 'Ajouter un contributeur',
+    'add_contributor' => 'Ajouter un contributeur pour {0}',
+    'edit_role' => 'Modifier le rôle de {0}',
+    'edit' => 'Modifier',
+    'remove' => 'Supprimer',
+    'list' => [
+        'username' => 'Identifiant',
+        'role' => 'Rôle',
+    ],
+    'form' => [
+        'user' => 'Utilisateur',
+        'user_placeholder' => 'Sélectionnez un utilisateur…',
+        'role' => 'Rôle',
+        'role_placeholder' => 'Sélectionnez son rôle…',
+        'submit_add' => 'Ajouter le contributeur',
+        'submit_edit' => 'Mettre à jour le rôle',
+    ],
+    'roles' => [
+        'podcast_admin' => 'Administrateur de Podcasts',
+    ],
+    'messages' => [
+        'removeOwnerContributorError' =>
+            'Vous ne pouvez pas retirer le propriétaire du podcast !',
+        'removeContributorSuccess' =>
+            'Vous avez retiré {username} de {podcastTitle}',
+        'alreadyAddedError' =>
+            'Le contributeur que vous essayez d’ajouter est déjà présent.',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Countries.php b/modules/Admin/Language/fr/Countries.php
new file mode 100644
index 0000000000000000000000000000000000000000..f1974eee7d3948045fc71ea5dc7c88641c0ae56d
--- /dev/null
+++ b/modules/Admin/Language/fr/Countries.php
@@ -0,0 +1,264 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * ISO 3166 country codes
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'AF ' => 'Afghanistan',
+    'ZA ' => 'Afrique Du Sud',
+    'AX ' => 'Ã…land, ÃŽles',
+    'AL ' => 'Albanie',
+    'DZ ' => 'Algérie',
+    'DE ' => 'Allemagne',
+    'AD ' => 'Andorre',
+    'AO ' => 'Angola',
+    'AI ' => 'Anguilla',
+    'AQ ' => 'Antarctique',
+    'AG ' => 'Antigua-Et-Barbuda',
+    'SA ' => 'Arabie Saoudite',
+    'AR ' => 'Argentine',
+    'AM ' => 'Arménie',
+    'AW ' => 'Aruba',
+    'AU ' => 'Australie',
+    'AT ' => 'Autriche',
+    'AZ ' => 'Azerbaïdjan',
+    'BS ' => 'Bahamas',
+    'BH ' => 'Bahreïn',
+    'BD ' => 'Bangladesh',
+    'BB ' => 'Barbade',
+    'BY ' => 'Bélarus',
+    'BE ' => 'Belgique',
+    'BZ ' => 'Belize',
+    'BJ ' => 'Bénin',
+    'BM ' => 'Bermudes',
+    'BT ' => 'Bhoutan',
+    'BO ' => 'Bolivie, État Plurinational De',
+    'BQ ' => 'Bonaire, Saint-Eustache Et Saba',
+    'BA ' => 'Bosnie-Herzégovine',
+    'BW ' => 'Botswana',
+    'BV ' => 'Bouvet, ÃŽle',
+    'BR ' => 'Brésil',
+    'BN ' => 'Brunéi Darussalam',
+    'BG ' => 'Bulgarie',
+    'BF ' => 'Burkina Faso',
+    'BI ' => 'Burundi',
+    'KY ' => 'Caïmanes, Îles',
+    'KH ' => 'Cambodge',
+    'CM ' => 'Cameroun',
+    'CA ' => 'Canada',
+    'CV ' => 'Cabo Verde',
+    'CF ' => 'Centrafricaine, République',
+    'CL ' => 'Chili',
+    'CN ' => 'Chine',
+    'CX ' => 'Christmas, ÃŽle',
+    'CY ' => 'Chypre',
+    'CC ' => 'Cocos (Keeling), ÃŽles',
+    'CO ' => 'Colombie',
+    'KM ' => 'Comores',
+    'CG ' => 'Congo',
+    'CD ' => 'Congo, La République Démocratique Du',
+    'CK ' => 'Cook, ÃŽles',
+    'KR ' => 'Corée, République De',
+    'KP ' => 'Corée, République Populaire Démocratique De',
+    'CR ' => 'Costa Rica',
+    'CI ' => 'Côte D’ivoire',
+    'HR ' => 'Croatie',
+    'CU ' => 'Cuba',
+    'CW ' => 'Curaçao',
+    'DK ' => 'Danemark',
+    'DJ ' => 'Djibouti',
+    'DO ' => 'Dominicaine, République',
+    'DM ' => 'Dominique',
+    'EG ' => 'Égypte',
+    'SV ' => 'El Salvador',
+    'AE ' => 'Émirats Arabes Unis',
+    'EC ' => 'Équateur',
+    'ER ' => 'Érythrée',
+    'ES ' => 'Espagne',
+    'EE ' => 'Estonie',
+    'US ' => 'États-Unis',
+    'ET ' => 'Éthiopie',
+    'FK ' => 'Falkland, ÃŽles (Malvinas)',
+    'FO ' => 'Féroé, Îles',
+    'FJ ' => 'Fidji',
+    'FI ' => 'Finlande',
+    'FR ' => 'France',
+    'GA ' => 'Gabon',
+    'GM ' => 'Gambie',
+    'GE ' => 'Géorgie',
+    'GS ' => 'Géorgie Du Sud Et Les Îles Sandwich Du Sud',
+    'GH ' => 'Ghana',
+    'GI ' => 'Gibraltar',
+    'GR ' => 'Grèce',
+    'GD ' => 'Grenade',
+    'GL ' => 'Groenland',
+    'GP ' => 'Guadeloupe',
+    'GU ' => 'Guam',
+    'GT ' => 'Guatemala',
+    'GG ' => 'Guernesey',
+    'GN ' => 'Guinée',
+    'GW ' => 'Guinée-Bissau',
+    'GQ ' => 'Guinée Équatoriale',
+    'GY ' => 'Guyana',
+    'GF ' => 'Guyane Française',
+    'HT ' => 'Haïti',
+    'HM ' => 'Heard Et Macdonald, ÃŽles',
+    'HN ' => 'Honduras',
+    'HK ' => 'Hong Kong',
+    'HU ' => 'Hongrie',
+    'IM ' => 'ÃŽle De Man',
+    'UM ' => 'Îles Mineures Éloignées Des États-Unis',
+    'VG ' => 'ÃŽles Vierges Britanniques',
+    'VI ' => 'Îles Vierges Des États-Unis',
+    'IN ' => 'Inde',
+    'ID ' => 'Indonésie',
+    'IR ' => "Iran, République Islamique D'",
+    'IQ ' => 'Iraq',
+    'IE ' => 'Irlande',
+    'IS ' => 'Islande',
+    'IL ' => 'Israël',
+    'IT ' => 'Italie',
+    'JM ' => 'Jamaïque',
+    'JP ' => 'Japon',
+    'JE ' => 'Jersey',
+    'JO ' => 'Jordanie',
+    'KZ ' => 'Kazakhstan',
+    'KE ' => 'Kenya',
+    'KG ' => 'Kirghizistan',
+    'KI ' => 'Kiribati',
+    'KW ' => 'Koweït',
+    'LA ' => 'Lao, République Démocratique Populaire',
+    'LS ' => 'Lesotho',
+    'LV ' => 'Lettonie',
+    'LB ' => 'Liban',
+    'LR ' => 'Libéria',
+    'LY ' => 'Libye',
+    'LI ' => 'Liechtenstein',
+    'LT ' => 'Lituanie',
+    'LU ' => 'Luxembourg',
+    'MO ' => 'Macao',
+    'MK ' => 'République De Macédoine',
+    'MG ' => 'Madagascar',
+    'MY ' => 'Malaisie',
+    'MW ' => 'Malawi',
+    'MV ' => 'Maldives',
+    'ML ' => 'Mali',
+    'MT ' => 'Malte',
+    'MP ' => 'Mariannes Du Nord, ÃŽles',
+    'MA ' => 'Maroc',
+    'MH ' => 'Marshall, ÃŽles',
+    'MQ ' => 'Martinique',
+    'MU ' => 'Maurice',
+    'MR ' => 'Mauritanie',
+    'YT ' => 'Mayotte',
+    'MX ' => 'Mexique',
+    'FM ' => 'Micronésie, États Fédérés De',
+    'MD ' => 'Moldavie',
+    'MC ' => 'Monaco',
+    'MN ' => 'Mongolie',
+    'ME ' => 'Monténégro',
+    'MS ' => 'Montserrat',
+    'MZ ' => 'Mozambique',
+    'MM ' => 'Myanmar',
+    'NA ' => 'Namibie',
+    'N/A' => 'Non Applicable (IP locale…)',
+    'NR ' => 'Nauru',
+    'NP ' => 'Népal',
+    'NI ' => 'Nicaragua',
+    'NE ' => 'Niger',
+    'NG ' => 'Nigéria',
+    'NU ' => 'Niué',
+    'NF ' => 'Norfolk, ÃŽle',
+    'NO ' => 'Norvège',
+    'NC ' => 'Nouvelle-Calédonie',
+    'NZ ' => 'Nouvelle-Zélande',
+    'IO ' => "Océan Indien, Territoire Britannique De L'",
+    'OM ' => 'Oman',
+    'UG ' => 'Ouganda',
+    'UZ ' => 'Ouzbékistan',
+    'PK ' => 'Pakistan',
+    'PW ' => 'Palaos',
+    'PS ' => 'État De Palestine',
+    'PA ' => 'Panama',
+    'PG ' => 'Papouasie-Nouvelle-Guinée',
+    'PY ' => 'Paraguay',
+    'NL ' => 'Pays-Bas',
+    'PE ' => 'Pérou',
+    'PH ' => 'Philippines',
+    'PN ' => 'Pitcairn',
+    'PL ' => 'Pologne',
+    'PF ' => 'Polynésie Française',
+    'PR ' => 'Porto Rico',
+    'PT ' => 'Portugal',
+    'QA ' => 'Qatar',
+    'RE ' => 'Réunion',
+    'RO ' => 'Roumanie',
+    'GB ' => 'Royaume-Uni',
+    'RU ' => 'Russie, Fédération De',
+    'RW ' => 'Rwanda',
+    'EH ' => 'Sahara Occidental',
+    'BL ' => 'Saint-Barthélemy',
+    'KN ' => 'Saint-Kitts-Et-Nevis',
+    'SM ' => 'Saint-Marin',
+    'MF ' => 'Saint-Martin (Partie Française)',
+    'SX ' => 'Saint-Martin (Partie Néerlandaise)',
+    'PM ' => 'Saint-Pierre-Et-Miquelon',
+    'VA ' => 'Saint-Siège (État De La Cité Du Vatican)',
+    'VC ' => 'Saint-Vincent-Et-Les-Grenadines',
+    'SH ' => 'Sainte-Hélène, Ascension Et Tristan Da Cunha',
+    'LC ' => 'Sainte-Lucie',
+    'SB ' => 'Salomon, ÃŽles',
+    'WS ' => 'Samoa',
+    'AS ' => 'Samoa Américaines',
+    'ST ' => 'Sao Tomé-Et-Principe',
+    'SN ' => 'Sénégal',
+    'RS ' => 'Serbie',
+    'SC ' => 'Seychelles',
+    'SL ' => 'Sierra Leone',
+    'SG ' => 'Singapour',
+    'SK ' => 'Slovaquie',
+    'SI ' => 'Slovénie',
+    'SO ' => 'Somalie',
+    'SD ' => 'Soudan',
+    'SS ' => 'Soudan Du Sud',
+    'LK ' => 'Sri Lanka',
+    'SE ' => 'Suède',
+    'CH ' => 'Suisse',
+    'SR ' => 'Suriname',
+    'SJ ' => 'Svalbard Et ÃŽle Jan Mayen',
+    'SZ ' => 'Eswatini',
+    'SY ' => 'Syrienne, République Arabe',
+    'TJ ' => 'Tadjikistan',
+    'TW ' => 'Taïwan, Province De Chine',
+    'TZ ' => 'Tanzanie, République Unie De',
+    'TD ' => 'Tchad',
+    'CZ ' => 'Tchéquie',
+    'TF ' => 'Terres Australes Françaises',
+    'TH ' => 'Thaïlande',
+    'TL ' => 'Timor-Leste',
+    'TG ' => 'Togo',
+    'TK ' => 'Tokelau',
+    'TO ' => 'Tonga',
+    'TT ' => 'Trinité-Et-Tobago',
+    'TN ' => 'Tunisie',
+    'TM ' => 'Turkménistan',
+    'TC ' => 'Turks Et Caïques, Îles',
+    'TR ' => 'Turquie',
+    'TV ' => 'Tuvalu',
+    'UA ' => 'Ukraine',
+    'UY ' => 'Uruguay',
+    'VU ' => 'Vanuatu',
+    'VE ' => 'Venezuela, République Bolivarienne Du',
+    'VN ' => 'Viet Nam',
+    'WF ' => 'Wallis-Et-Futuna',
+    'YE ' => 'Yémen',
+    'ZM ' => 'Zambie',
+    'ZW ' => 'Zimbabwe',
+];
diff --git a/modules/Admin/Language/fr/Episode.php b/modules/Admin/Language/fr/Episode.php
new file mode 100644
index 0000000000000000000000000000000000000000..fc3d49364ebfb4b7a95e982dbbc8e38b81a668de
--- /dev/null
+++ b/modules/Admin/Language/fr/Episode.php
@@ -0,0 +1,175 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'season' => 'Saison {seasonNumber}',
+    'season_abbr' => 'S{seasonNumber}',
+    'number' => 'Épisode {episodeNumber}',
+    'number_abbr' => 'Ep. {episodeNumber}',
+    'season_episode' => 'Saison {seasonNumber} épisode {episodeNumber}',
+    'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}',
+    'back_to_episodes' => 'Retour aux épisodes de {podcast}',
+    'comments' => 'Commentaires',
+    'activity' => 'Activité',
+    'description' => 'Description',
+    'number_of_comments' => '{numberOfComments, plural,
+        one {# commentaire}
+        other {# commentaires}
+    }',
+    'all_podcast_episodes' => 'Tous les épisodes du podcast',
+    'back_to_podcast' => 'Revenir au podcast',
+    'edit' => 'Modifier',
+    'publish' => 'Publier',
+    'publish_edit' => 'Modifier la publication',
+    'unpublish' => 'Dépublier',
+    'publish_error' => 'L’épisode est déjà publié.',
+    'publish_edit_error' => 'L’épisode est déjà publié.',
+    'publish_cancel_error' => 'L’épisode est déjà publié.',
+    'unpublish_error' => 'L’épisode n’est pas publié.',
+    'delete' => 'Supprimer',
+    'go_to_page' => 'Voir',
+    'create' => 'Ajouter un épisode',
+    'publication_status' => [
+        'published' => 'Publié',
+        'scheduled' => 'Planifié',
+        'not_published' => 'Non publié',
+    ],
+    'list' => [
+        'episode' => 'Épisode',
+        'visibility' => 'Visibilité',
+        'comments' => 'Commentaires',
+        'actions' => 'Actions',
+    ],
+    'form' => [
+        'warning' =>
+            'En cas d’erreur fatale, essayez d’augmenter les valeurs de `memory_limit`, `upload_max_filesize` et `post_max_size` dans votre fichier de configuration php puis redémarrez votre serveur web.<br />Les valeurs doivent être plus grandes que le fichier audio que vous souhaitez téléverser.',
+        'audio_file' => 'Fichier audio',
+        'audio_file_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
+        'info_section_title' => 'Informations épisode',
+        'info_section_subtitle' => '',
+        'image' => 'Image de couverture',
+        'image_hint' =>
+            'Si vous ne définissez pas d’image, celle du podcast sera utilisée à la place.',
+        'title' => 'Titre',
+        'title_hint' =>
+            'Doit contenir un titre d’épisode clair et concis. Ne précisez ici aucun numéro de saison ou d’épisode.',
+        'permalink' => 'Lien permanent',
+        'season_number' => 'Saison',
+        'episode_number' => 'Épisode',
+        'type' => [
+            'label' => 'Type',
+            'hint' =>
+                '- <strong>complet</strong>: épisode complet.<br/>- <strong>bande-annonce</strong>: extrait court, promotionnel du podcast.<br/>- <strong>bonus</strong> :  contenu supplémentaire pour le podcast (par exemple des informations sur les coulisses ou des interviews avec les acteurs) ou du contenu promotionnel croisé pour un autre podcast.',
+            'full' => 'Complet',
+            'trailer' => 'Bande-annonce',
+            'bonus' => 'Bonus',
+        ],
+        'parental_advisory' => [
+            'label' => 'Avertissement parental',
+            'hint' => 'L’épisode contient-il un contenu explicite ?',
+            'undefined' => 'non défini',
+            'clean' => 'Convenable',
+            'explicit' => 'Explicite',
+        ],
+        'show_notes_section_title' => 'Notes d’épisode (Show Notes)',
+        'show_notes_section_subtitle' =>
+            'Jusque 4000 caractères, soyez clairs et concis. Les notes d’épisode aident les auditeurs potentiels à le trouver.',
+        'description' => 'Description',
+        'description_footer' => 'Pied de description',
+        'description_footer_hint' =>
+            'Ce texte est ajouté à la fin de chaque description d’épisode, c’est un bon endroit pour placer vos liens sociaux par exemple.',
+        'additional_files_section_title' => 'Fichiers additionels',
+        'additional_files_section_subtitle' =>
+            'Ces fichiers pourront être utilisées par d’autres plate-formes pour procurer une meilleure expérience à vos auditeurs.<br />Consulter le {podcastNamespaceLink} pour plus d’informations.',
+        'location_section_title' => 'Localisation',
+        'location_section_subtitle' => 'De quel lieu cet épisode parle-t-il ?',
+        'location_name' => 'Nom ou adresse du lieu',
+        'location_name_hint' => 'Ce lieu peut être réel ou fictif',
+        'transcript' => 'Transcription ou sous-titrage',
+        'transcript_hint' =>
+            'Les formats autorisés sont txt, html, srt ou json.',
+        'transcript_file' => 'Fichier de transcription',
+        'transcript_file_remote_url' =>
+            'URL distante pour le fichier de transcription',
+        'transcript_file_delete' => 'Supprimer le fichier de transcription',
+        'chapters' => 'Chapitrage',
+        'chapters_hint' => 'Le fichier doit être en format “JSON Chapters”.',
+        'chapters_file' => 'Fichier de chapitrage',
+        'chapters_file_remote_url' =>
+            'URL distante pour le fichier de chapitrage',
+        'chapters_file_delete' => 'Supprimer le fichier de chapitrage',
+        'advanced_section_title' => 'Paramètres avancés',
+        'advanced_section_subtitle' =>
+            'Si vous avez besoin d’une balise que Castopod ne couvre pas, définissez-la ici.',
+        'custom_rss' => 'Balises RSS personnalisées pour l’épisode',
+        'custom_rss_hint' => 'Ceci sera injecté dans la balise ❬item❭.',
+        'block' => 'L’épisode doit être masqué de toutes les plateformes',
+        'block_hint' =>
+            'La visibilité de l’épisode. Si vous souhaitez retirer cet épisode de l’index Apple, activez ce champ.',
+        'submit_create' => 'Créer l’épisode',
+        'submit_edit' => 'Enregistrer l’épisode',
+    ],
+    'publish_form' => [
+        'back_to_episode_dashboard' => 'Retour au tableau de bord de l’épisode',
+        'post' => 'Votre message de publication',
+        'post_hint' =>
+            'Écrivez un message pour annoncer la publication de votre épisode. Le message sera diffusé à toutes les personnes qui vous suivent dans le fédiverse et mis en évidence sur la page d’accueil de votre podcast.',
+        'publication_date' => 'Date de publication',
+        'publication_date_clear' => 'Effacer la date de publication',
+        'publication_date_hint' =>
+            'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
+        'publication_method' => [
+            'now' => 'Maintenant',
+            'schedule' => 'Planifier',
+        ],
+        'scheduled_publication_date' => 'Date de publication programmée',
+        'scheduled_publication_date_clear' => 'Effacer la date de publication',
+        'scheduled_publication_date_hint' =>
+            'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
+        'submit' => 'Publier',
+        'submit_edit' => 'Modifier la publication',
+        'cancel_publication' => 'Annuler la publication',
+        'message_warning' => 'Vous n’avez pas saisi de message pour l’annonce de votre épisode !',
+        'message_warning_hint' => 'Ajouter un message augmente l’engagement social, menant à une meilleure visibilité pour votre épisode.',
+        'message_warning_submit' => 'Publish quand même',
+    ],
+    'soundbites' => 'Extraits sonores',
+    'soundbites_form' => [
+        'title' => 'Modifier les extraits sonores',
+        'info_section_title' => 'Extraits sonores de l’épisode',
+        'info_section_subtitle' =>
+            'Ajouter, modifier ou supprimer des extraits sonores',
+        'start_time' => 'Début',
+        'start_time_hint' =>
+            'La première seconde de l’extrait sonore, cela peut être un nombre décimal.',
+        'duration' => 'Durée',
+        'duration_hint' =>
+            'La durée de l’extrait sonore (en secondes), cela peut être un nombre décimal.',
+        'label' => 'Libellé',
+        'label_hint' => 'Texte qui sera affiché.',
+        'play' => 'Écouter l’extrait sonore',
+        'delete' => 'Supprimer l’extrait sonore',
+        'bookmark' =>
+            'Cliquez pour récupérer la position actuelle, cliquez à nouveau pour récupérer la durée.',
+        'submit_edit' => 'Enregistrer tous les extraits sonores',
+    ],
+    'embeddable_player' => [
+        'add' => 'Ajouter un lecteur intégré',
+        'title' => 'Lecteur intégré',
+        'label' =>
+            'Sélectionnez une couleur de thème, copiez le code dans le presse-papier, puis collez-le sur votre site internet.',
+        'clipboard_iframe' => 'Copier le lecteur dans le presse papier',
+        'clipboard_url' => 'Copier l’adresse dans le presse papier',
+        'dark' => 'Sombre',
+        'dark-transparent' => 'Sombre transparent',
+        'light' => 'Clair',
+        'light-transparent' => 'Clair transparent',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Fediverse.php b/modules/Admin/Language/fr/Fediverse.php
new file mode 100644
index 0000000000000000000000000000000000000000..fa6b48972c7c5465fd0c89debac46fee159df9b6
--- /dev/null
+++ b/modules/Admin/Language/fr/Fediverse.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'block_lists' => 'Listes de blocage',
+    'block_lists_form' => [
+        'blocked_users' => 'Utilisateurs bloqués',
+        'blocked_users_hint' =>
+            'Entrez les pseudonymes @utilisateur@domaine séparés par une virgule.',
+        'blocked_domains' => 'Domaines bloqués',
+        'blocked_domains_hint' =>
+            'Entrez les noms de domaine séparés par une virgule.',
+        'submit' => 'Sauvegarder les listes',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Home.php b/modules/Admin/Language/fr/Home.php
new file mode 100644
index 0000000000000000000000000000000000000000..ab847e8f412484def718d5c7f5724c427fac4faf
--- /dev/null
+++ b/modules/Admin/Language/fr/Home.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'all_podcasts' => 'Tous les podcasts',
+    'no_podcast' => 'Aucun podcast trouvé',
+];
diff --git a/modules/Admin/Language/fr/Install.php b/modules/Admin/Language/fr/Install.php
new file mode 100644
index 0000000000000000000000000000000000000000..64eebae16403a1e33ef5bbe53f95cd2c66be700a
--- /dev/null
+++ b/modules/Admin/Language/fr/Install.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'manual_config' => 'Configuration manuelle',
+    'manual_config_subtitle' =>
+        'Créez un fichier `.env` qui contient tous vos paramètres puis rafraichissez la page pour continuer l’installation.',
+    'form' => [
+        'instance_config' => 'Paramètres de l’instance',
+        'hostname' => 'Nom d’hôte',
+        'media_base_url' => 'Adresse racine des médias',
+        'media_base_url_hint' =>
+            'Si vous utilisez un CDN et/ou un service de mesure d’audience externe, vous pouvez les définir ici.',
+        'admin_gateway' => 'Adresse d’administration',
+        'admin_gateway_hint' =>
+            'Le chemin pour accéder à l’administration (par exemple https://example.com/cp-admin). Il est défini par défaut à cp-admin, nous vous recommandons de le changer par mesure de sécurité.',
+        'auth_gateway' => 'Adresse d’authentification',
+        'auth_gateway_hint' =>
+            'Le chemin des pages d’authentication (par exemple https://example.fr/cp-auth). Il est défini par défaut à cp-auth, nous vous recommandons de le changer par mesure de sécurité.',
+        'database_config' => 'Paramètres de base de données',
+        'database_config_hint' =>
+            'Castopod doit se connecter à votre base de données MySQL (ou MariaDB). Si vous ne disposez pas de ces informations, merci de contacter l’administrateur du serveur.',
+        'db_hostname' => 'Nom d’hôte (ou IP) de la base de données',
+        'db_name' => 'Nom de la base de données',
+        'db_username' => 'Utilisateur de base de données',
+        'db_password' => 'Mot de passe de base de données',
+        'db_prefix' => 'Préfixe des tables',
+        'db_prefix_hint' =>
+            'Le préfixe des noms de tables de Castopod, laissez la valeur par défaut si vous ne savez pas de quoi il s’agit.',
+        'cache_config' => 'Paramètres de cache',
+        'cache_config_hint' =>
+            'Sélectionnez votre gestionnaire de cache préféré. Laissez la valeur par défaut si vous ne savez pas de quoi il s’agit.',
+        'cache_handler' => 'Gestionnaire de cache',
+        'cacheHandlerOptions' => [
+            'file' => 'Fichiers',
+            'redis' => 'Redis',
+            'predis' => 'Predis',
+        ],
+        'next' => 'Suivant',
+        'submit' => 'Terminer l’installation',
+        'create_superadmin' => 'Créer un compte super-utilisateur',
+        'email' => 'E-mail',
+        'username' => 'Identifiant',
+        'password' => 'Mot de passe',
+    ],
+    'messages' => [
+        'createSuperAdminSuccess' =>
+            'Le compte super-utilisateur a bien été créé. Connectez-vous et commencez à podcaster !',
+        'databaseConnectError' =>
+            'Castopod n’a pas pu se connecter à la base de données. Modifier les paramètres de base de données et essayer à nouveau.',
+        'writeError' =>
+            'Impossible de créer/écrire le fichier `.env`. Créez manuellement un fichier `.env` en copiant le modèle `.env.example` fourni avec Castopod.',
+    ],
+];
diff --git a/modules/Admin/Language/fr/MyAccount.php b/modules/Admin/Language/fr/MyAccount.php
new file mode 100644
index 0000000000000000000000000000000000000000..837b04f4604ab6ddc9614dd8d396c6a1781e9937
--- /dev/null
+++ b/modules/Admin/Language/fr/MyAccount.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'info' => 'Informations de mon compte',
+    'changePassword' => 'Modifier mon mot de passe',
+    'messages' => [
+        'wrongPasswordError' =>
+            'Le mot de passe que vous avez saisi est invalide.',
+        'passwordChangeSuccess' =>
+            'Le mot de passe a été modifié avec succès !',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Navigation.php b/modules/Admin/Language/fr/Navigation.php
new file mode 100644
index 0000000000000000000000000000000000000000..357b64ffabadbb2881ed2763257a0b7effe76756
--- /dev/null
+++ b/modules/Admin/Language/fr/Navigation.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'go_to_website' => 'Visiter le site',
+    'dashboard' => 'Tableau de bord',
+    'admin' => 'Accueil',
+    'podcasts' => 'Podcasts',
+    'podcast-list' => 'Tous les podcasts',
+    'podcast-create' => 'Créer un podcast',
+    'podcast-import' => 'Importer un podcast',
+    'persons' => 'Intervenants',
+    'person-list' => 'Tous les intervenants',
+    'person-create' => 'Nouvel intervenant',
+    'fediverse' => 'Fédiverse',
+    'fediverse-blocked_actors' => 'Utilisateurs blockés',
+    'fediverse-blocked_domains' => 'Domaines blockés',
+    'users' => 'Utilisateurs',
+    'user-list' => 'Tous les utilisateurs',
+    'user-create' => 'Créer un utilisateur',
+    'pages' => 'Pages',
+    'page-list' => 'Toutes les pages',
+    'page-create' => 'Créer une page',
+    'account' => [
+        'my-account' => 'Mon compte',
+        'change-password' => 'Modifier le mot de passe',
+        'logout' => 'Déconnexion',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Page.php b/modules/Admin/Language/fr/Page.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a0b21a5524de265775ac8234d9efe89002b19b2
--- /dev/null
+++ b/modules/Admin/Language/fr/Page.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'back_to_home' => 'Retour à l’accueil',
+    'page' => 'Page',
+    'all_pages' => 'Toutes les pages',
+    'create' => 'Créer une page',
+    'go_to_page' => 'Aller à la page',
+    'edit' => 'Modifier la page',
+    'delete' => 'Supprimer la page',
+    'form' => [
+        'title' => 'Titre',
+        'permalink' => 'Lien permanent',
+        'content' => 'Contenu',
+        'submit_create' => 'Créer la page',
+        'submit_edit' => 'Enregistrer',
+    ],
+    'messages' => [
+        'createSuccess' => 'La page {pageTitle} a été créée avec succès !',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Pager.php b/modules/Admin/Language/fr/Pager.php
new file mode 100644
index 0000000000000000000000000000000000000000..4dcbffa71abed817cef2048e0aba1b5f7930380e
--- /dev/null
+++ b/modules/Admin/Language/fr/Pager.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'pageNavigation' => 'Navigation',
+    'first' => 'Première',
+    'previous' => 'Précédent',
+    'next' => 'Suivant',
+    'last' => 'Dernière',
+    'older' => 'Plus ancien',
+    'newer' => 'Plus récent',
+    'invalidTemplate' => '{0} n’est pas un modèle valide.',
+    'invalidPaginationGroup' => '{0} n’est pas un groupe valide.',
+];
diff --git a/modules/Admin/Language/fr/Person.php b/modules/Admin/Language/fr/Person.php
new file mode 100644
index 0000000000000000000000000000000000000000..ad2aeda23c6635bd1de2b5e86cfdf2600e78be06
--- /dev/null
+++ b/modules/Admin/Language/fr/Person.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'persons' => 'Intervenants',
+    'all_persons' => 'Tous les intervenants',
+    'no_person' => 'Aucun intervenant trouvé !',
+    'create' => 'Créer un intervenant',
+    'view' => 'Voir l’intervenant',
+    'edit' => 'Modifier l’intervenant',
+    'delete' => 'Supprimer l’intervenant',
+    'form' => [
+        'identity_section_title' => 'Identité',
+        'identity_section_subtitle' => 'Qui intervient sur le podcast',
+        'image' => 'Photo',
+        'image_size_hint' =>
+            'L’image doit être carrée et avoir au moins 400px de largeur et de hauteur.',
+        'full_name' => 'Nom complet',
+        'full_name_hint' => 'Le nom complet ou le pseudonyme de l’intervenant',
+        'unique_name' => 'Nom unique',
+        'unique_name_hint' => 'Utilisé pour les URLs',
+        'information_url' => 'Adresse d’information',
+        'information_url_hint' =>
+            'URL pointant vers des informations relatives à l’intervenant, telle qu’une page personnelle ou une page de profil sur une plateforme tierce.',
+        'submit_create' => 'Créer l’intervenant',
+        'submit_edit' => 'Enregistrer l’intervenant',
+    ],
+    'podcast_form' => [
+        'title' => 'Gérer les intervenants',
+        'manage_section_title' => 'Gestion',
+        'manage_section_subtitle' => 'Retirer des intervenants de ce podcast',
+        'add_section_title' => 'Ajouter des intervenants à ce podcast',
+        'add_section_subtitle' =>
+            'Vous pouvez sélectionner plusieurs intervenants et rôles.',
+        'person' => 'Intervenants',
+        'person_hint' =>
+            'Vous pouvez selectionner un ou plusieurs intervenants ayant les mêmes rôles. Les intervenants doivent avoir été préalablement créés.',
+        'group_role' => 'Groupes et rôles',
+        'group_role_hint' =>
+            'Vous pouvez sélectionner aucun, un ou plusieurs groupes et rôles par intervenant.',
+        'submit_add' => 'Ajouter un/des intervenant(s)',
+        'remove' => 'Retirer',
+    ],
+    'episode_form' => [
+        'title' => 'Gérer les intervenants',
+        'manage_section_title' => 'Gestion',
+        'manage_section_subtitle' => 'Retirer des intervenants de cet épisode',
+        'add_section_title' => 'Ajouter des intervenants à cet épisode',
+        'add_section_subtitle' =>
+            'Vous pouvez sélectionner plusieurs intervenants et rôles.',
+        'person' => 'Intervenants',
+        'person_hint' =>
+            'Vous pouvez selectionner un ou plusieurs intervenants ayant les mêmes rôles. Les intervenants doivent avoir été préalablement créés.',
+        'group_role' => 'Groupes et rôles',
+        'group_role_hint' =>
+            'Vous pouvez sélectionner aucun, un ou plusieurs groupes et rôles par intervenant.',
+        'submit_add' => 'Ajouter un/des intervenant(s)',
+        'remove' => 'Retirer',
+    ],
+    'credits' => 'Crédits',
+];
diff --git a/modules/Admin/Language/fr/PersonsTaxonomy.php b/modules/Admin/Language/fr/PersonsTaxonomy.php
deleted file mode 100644
index c32e37f63067407d3639e624d90d883b17b0810c..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/fr/PersonsTaxonomy.php
+++ /dev/null
@@ -1,470 +0,0 @@
-<?php
-
-/**
- * @copyright  2021 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://podlibre.org/
- */
-
-/* Autogenerated from https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-fr.json on 2021-09-06T09:10:53+00:00 */
-
-return array (
-  'persons' => 
-  array (
-    'creative_direction' => 
-    array (
-      'label' => 'Direction de Création',
-      'roles' => 
-      array (
-        'director' => 
-        array (
-          'label' => 'Réalisat·eur·rice',
-          'description' => 'Le directeur est à la tête de toute la production créative, des détails créatifs à la logistique. Il n\'y a généralement qu\'un seul réalisateur pour une production. Ce rôle est principalement vu dans les podcasts de fiction.',
-          'example' => 'Jenna Knorr pour « Bienvenue à Tinsel Town »',
-        ),
-        'assistant_director' => 
-        array (
-          'label' => 'Réalisat·eur·rice-Assistant·e',
-          'description' => 'Le directeur adjoint est une liaison entre le réalisateur et le reste de la production, coordonnant souvent la logistique quotidienne de la production. Il peut y avoir plusieurs directeurs adjoints sur un projet. Ce rôle est principalement vu dans les podcasts de fiction.',
-          'example' => 'William Wright pour « Inn Between »',
-        ),
-        'executive_producer' => 
-        array (
-          'label' => 'Product·eur·rice exécut·if·ive',
-          'description' => 'Le producteur exécutif est le producteur principal d\'une production. Le rôle peut varier en termes de contrôle créatif, certains « EP » détenant la direction créative d\'un podcast (en fait en assumant le rôle de réalisateur), tandis que d\'autres peuvent adopter une approche plus pratique. Le producteur exécutif a peut-être collecté des fonds pour financer la production, mais ce n\'est pas une responsabilité nécessaire du rôle.',
-          'example' => 'Jane Rotonda pour « The Larry Meiller Show »',
-        ),
-        'senior_producer' => 
-        array (
-          'label' => 'Premi·er·ère Réalisat·eur·rice',
-          'description' => 'Le producteur principal est le deuxième producteur le plus ancien de la production (deuxième après le producteur exécutif). Ils supervisent les producteurs et la direction générale et la logistique de l\'ensemble de la production.',
-          'example' => 'Dr. Jeremy Weisz de « INspired INsider »',
-        ),
-        'producer' => 
-        array (
-          'label' => 'Product·eur·rice',
-          'description' => 'Le producteur coordonne et exécute la production du podcast. Ces tâches peuvent inclure l\'aide à l\'élaboration de la direction créative d\'un projet, la budgétisation, la recherche, la planification et la supervision de l\'édition et de la production finale.',
-          'example' => '',
-        ),
-        'associate_producer' => 
-        array (
-          'label' => 'Product·eur·rice Délégué·e',
-          'description' => 'Le producteur associé remplit une ou plusieurs fonctions de producteur qui lui sont déléguées par un producteur.',
-          'example' => 'Alex Baumhardt pour « APM Reports »',
-        ),
-        'development_producer' => 
-        array (
-          'label' => 'Product·eur·rice au Développement',
-          'description' => 'Le producteur de développement coordonne et exécute la direction de création de pré-production d\'un podcast. Leurs responsabilités consistent à trouver de nouvelles idées d\'épisodes et de séries et à travailler avec des écrivains et des chercheurs pour préparer le concept pour la production.',
-          'example' => '',
-        ),
-        'creative_director' => 
-        array (
-          'label' => 'Direct·eur·rice de la Création',
-          'description' => 'Le directeur de la création est responsable de la stratégie créative et de l\'exécution de toute une série. Souvent, ce rôle dépasse le contenu pour affecter les œuvres d\'art, la musique, les campagnes marketing, etc.',
-          'example' => 'Neil Druckmann sur « The Official The Last of Us »',
-        ),
-      ),
-    ),
-    'cast' => 
-    array (
-      'label' => 'Distribution',
-      'roles' => 
-      array (
-        'host' => 
-        array (
-          'label' => 'Présentat·eur·rice',
-          'description' => 'L\'hôte est le maître des cérémonies à l\'antenne du podcast et une présence constante sur chaque épisode (à l\'exception des hôtes invités et des épisodes alternatifs). Les tâches de l\'hôte peuvent inclure la réalisation d\'entrevues, l\'introduction d\'histoires et de segments, la narration, etc. Il peut y avoir plus d\'un hôte par podcast ou épisode.',
-          'example' => 'Joe Rogan pour « The Joe Rogan Experience »',
-        ),
-        'co_host' => 
-        array (
-          'label' => 'Co-Présentat·eur·rice',
-          'description' => 'Le co-animateur remplit bon nombre des mêmes tâches que l\'hôte, tout en prenant une présence secondaire sur le podcast.',
-          'example' => 'Dax Shepard pour « Armchair Expert »',
-        ),
-        'guest_host' => 
-        array (
-          'label' => 'Présentat·eur·rice Exceptionnel·le',
-          'description' => 'L\'hôte invité remplit toutes les fonctions du rôle d\'hôte traditionnel, mais le fait à titre temporaire. Souvent en une seule apparition ou en une courte période d\'épisodes.',
-          'example' => 'Erica Kelly sur « Let\'s Taco \'Bout Women and True Crime »',
-        ),
-        'guest' => 
-        array (
-          'label' => 'Invité·e',
-          'description' => 'L\'invité est une partie extérieure qui fait une apparition à l\'antenne sur un épisode, souvent en tant que participant à un panel ou sujet de l\'interview.',
-          'example' => 'Lewis Brindley pour « Triforce!"',
-        ),
-        'voice_actor' => 
-        array (
-          'label' => 'Comédien·ne Voix',
-          'description' => 'The Voice Actor donne une performance dans laquelle ils prêtent leur voix au rôle d\'un personnage dans un épisode de podcast. Alors que la majorité des rôles de doublage seront fictifs, le rôle de doubleur peut également couvrir des reconstitutions de conversations et de personnes réelles.',
-          'example' => 'Venk Potula pour « Masala Jones »',
-        ),
-        'narrator' => 
-        array (
-          'label' => 'Narrat·eur·rice',
-          'description' => 'The Narrator donne une performance dans laquelle racontent l\'exposition d\'une histoire fictive ou non fictive, souvent de manière scénarisée. Le narrateur peut également interpréter des voix de personnages dans l\'histoire, à condition qu\'ils conservent toujours le rôle de conteur d\'exposition ou de « voix de Dieu ».',
-          'example' => 'James Harvey Freetly pour « Lakeshore & Limbo »',
-        ),
-        'announcer' => 
-        array (
-          'label' => 'Annonc·eur·euse',
-          'description' => 'L\'annonceur donne de courtes performances vocales pour l\'introduction du podcast, des sujets d\'épisode, des segments, des invités, des prix, etc. L\'annonceur est secondaire par rapport à l\'hôte du podcast et effectue souvent ses introductions d\'une manière scénarisée et produite.',
-          'example' => 'Lydia Kapp pour « World Builders Anonymous »',
-        ),
-        'reporter' => 
-        array (
-          'label' => 'Journaliste',
-          'description' => 'Le journaliste trouve et étudie des nouvelles ou des histoires pour le podcast, interviewant souvent des sujets et menant des recherches. Le journaliste peut également être un poste à l\'antenne, car il transmet les idées de son enquête.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'writing' => 
-    array (
-      'label' => 'Écriture',
-      'roles' => 
-      array (
-        'author' => 
-        array (
-          'label' => 'Aut·eur·rice',
-          'description' => 'L\'auteur a écrit de la prose ou de la poésie initialement destinée à un texte qui est maintenant lu textuellement à l\'antenne.',
-          'example' => 'Heiko Martens pour « The Sigmund Freud Files »',
-        ),
-        'editorial_director' => 
-        array (
-          'label' => 'Direct·eur·rice de la Rédaction',
-          'description' => 'Le directeur de la rédaction dirige tous les départements de l\'organisation derrière le podcast et est responsable de la délégation des tâches aux membres du personnel et de leur gestion. Ils sont l\'éditeur le plus haut placé et sont responsables de la direction, de l\'exactitude et des décisions derrière le contenu de podcast.',
-          'example' => 'Christopher Twarowski pour « News Beat »',
-        ),
-        'co_writer' => 
-        array (
-          'label' => 'Co-rédact·eur·rice',
-          'description' => 'Le co-scénariste a écrit un podcast en partenariat avec 1 ou 2 autres écrivains, partageant ainsi le mérite de l\'arc créatif, du dialogue et de la narration.',
-          'example' => 'Max Eggers dans « THE LIGHTHOUSE »',
-        ),
-        'writer' => 
-        array (
-          'label' => 'Rédact·eur·rice',
-          'description' => 'The Writer a écrit l\'histoire ou le dialogue d\'un podcast. L\'écrivain est souvent impliqué dans l\'arc créatif d\'une production, mais ce n\'est pas une condition nécessaire. Les écrivains peuvent travailler dans des podcasts fictifs ou non fictifs.',
-          'example' => '',
-        ),
-        'songwriter' => 
-        array (
-          'label' => 'Aut·eur·rice Composit·eur·rice',
-          'description' => 'L\'auteur-compositeur a écrit les paroles et / ou la musique d\'accompagnement d\'une chanson originale créée pour le podcast et jouée sur un épisode.',
-          'example' => 'Ben Lapidus pour « Gay Future »',
-        ),
-        'guest_writer' => 
-        array (
-          'label' => 'Rédact·eur·rice Invité·e',
-          'description' => 'L\'écrivain invité remplit les fonctions d\'écrivain à titre temporaire, souvent sous la forme d\'un épisode unique ou d\'une courte période d\'épisodes. La distinction entre écrivain et écrivain invité dépend de la décision du podcast lui-même.',
-          'example' => 'Beth Crane pour « The Unseen Hour »',
-        ),
-        'story_editor' => 
-        array (
-          'label' => 'Rédact·eur·rice en Chef ',
-          'description' => 'L\'éditeur d\'histoire est responsable de la direction générale de l\'arc de l\'histoire et du développement des personnages d\'un podcast. Souvent vu dans les podcasts de fiction et documentaires.',
-          'example' => 'Gabrielle Loux pour « The NoSleep Podcast »',
-        ),
-        'managing_editor' => 
-        array (
-          'label' => 'Direct·eur·rice de la Publication',
-          'description' => 'Le rédacteur en chef supervise et coordonne les activités éditoriales des podcasts, en fournissant à la fois une édition détaillée et la gestion d\'une équipe de rédacteurs et d\'éditeurs pour s\'assurer que les délais et les budgets sont respectés.',
-          'example' => 'Flora Lichtman pour « Every Little Thing »',
-        ),
-        'script_editor' => 
-        array (
-          'label' => 'Chef·fe-Scénariste',
-          'description' => 'L\'éditeur de script fournit des notes et des modifications au script d\'enregistrement dans un rôle très pratique. L\'éditeur de script est principalement utilisé dans la fiction, les documentaires et les publicités où les enregistrements scénarisés sont répandus.',
-          'example' => 'Alex Rioux pour « Bienvenue à Tinsel Town: A Christmas Adventure »',
-        ),
-        'script_coordinator' => 
-        array (
-          'label' => 'Coordinat·eur·rice de scénario',
-          'description' => 'Le coordinateur du scénario emballe le script final avec des annotations qui reflètent une logistique spécifique et des indices créatifs pour l\'enregistrement et la production.',
-          'example' => 'Alex Rioux pour « Bienvenue à Tinsel Town: A Christmas Adventure »',
-        ),
-        'researcher' => 
-        array (
-          'label' => 'Enquêt·eur·rice',
-          'description' => 'Le chercheur coordonne la recherche et la vérification des informations qui peuvent ensuite être utilisées pour le contenu d\'un épisode de podcast, informant souvent la direction d\'une histoire en fonction de nouvelles informations découvertes.',
-          'example' => 'Dave Grave pour « The Zero Brain Podcast »',
-        ),
-        'editor' => 
-        array (
-          'label' => 'Édit·eur·rice',
-          'description' => 'L\'éditeur examine et prépare des scripts pour transmettre des informations de manière créative, précise et engageante.',
-          'example' => '',
-        ),
-        'fact_checker' => 
-        array (
-          'label' => 'Contrôl·eur·euse Qualité',
-          'description' => 'Le vérificateur de faits examine le contenu d\'un podcast pour vérifier l\'exactitude des faits et vérifie que l\'attribution des citations est correcte. Ils utilisent une variété d\'outils, y compris la recherche de tiers et la sensibilisation individuelle. Souvent, le vérificateur de faits fournira également des notes sur la façon dont la production peut éviter la confusion dans la livraison des informations dans l\'épisode.',
-          'example' => '',
-        ),
-        'translator' => 
-        array (
-          'label' => 'Traduct·eur·rice',
-          'description' => 'Le traducteur convertit le contenu d\'une langue à une autre pour le podcast. Cela peut être des interviews, des dialogues, des documents texte, etc. Le travail du traducteur peut être utilisé à l\'antenne ou en coulisses pendant le processus de production / recherche.',
-          'example' => '',
-        ),
-        'transcriber' => 
-        array (
-          'label' => 'Transcript·eur·rice',
-          'description' => 'Le transcripteur transforme les dialogues et les signaux audio en texte, qui peut être utilisé en interne pour les processus de production ou affiché publiquement pour les auditeurs.',
-          'example' => '',
-        ),
-        'logger' => 
-        array (
-          'label' => 'Archiviste',
-          'description' => 'The Logger examine et documente le contenu et les horodatages de l\'audio brut au service des producteurs et des éditeurs dans le processus de production.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'audio_production' => 
-    array (
-      'label' => 'Production Audio',
-      'roles' => 
-      array (
-        'studio_coordinator' => 
-        array (
-          'label' => 'Coordinat·eur·rice de Studio',
-          'description' => 'Le coordonnateur de studio gère le studio d\'enregistrement et les techniciens audio travaillant dans le studio au moment de l\'enregistrement.',
-          'example' => '',
-        ),
-        'technical_director' => 
-        array (
-          'label' => 'Direct·eur·rice Technique',
-          'description' => 'Le directeur technique supervise l\'enregistrement et la production du podcast car il est impliqué dans les technologies audio, y compris le matériel et les logiciels, et la gestion des rôles impliqués dans ces domaines.',
-          'example' => 'Adam Raymonda sur « Celebuzz\'d »',
-        ),
-        'technical_manager' => 
-        array (
-          'label' => 'Responsable Technique',
-          'description' => 'Le directeur technique coordonne une équipe d\'ingénieurs du son et de personnel de studio, dans l\'enregistrement et la production car il est impliqué dans les technologies audio, y compris le matériel et les logiciels.',
-          'example' => '',
-        ),
-        'audio_engineer' => 
-        array (
-          'label' => 'Ingénieur·e du Son',
-          'description' => 'L\'ingénieur audio aide à enregistrer et à produire de l\'audio en configurant des environnements d\'enregistrement, en surveillant le recodage et en apportant des ajustements techniques tout au long. L\'ingénieur audio est présent pendant le processus d\'enregistrement, effectuant le plus souvent des ajustements en temps réel. L\'ingénieur du son peut travailler avec des conversations, de la musique, des chansons ou tout autre type d\'audio.',
-          'example' => 'Peter Leonard de « Startup Podcast »',
-        ),
-        'remote_recording_engineer' => 
-        array (
-          'label' => 'Pren·eur·euse de Son sur Site',
-          'description' => 'L\'ingénieur d\'enregistrement à distance assure l\'enregistrement correct des conversations ayant lieu à plusieurs endroits sur une ligne téléphonique ou une connexion Internet. L\'ingénieur d\'enregistrement à distance évalue les différentes configurations d\'enregistrement et tente de les réconcilier en un son cohérent, tout en surveillant également le processus d\'enregistrement pour capturer le meilleur son possible.',
-          'example' => '',
-        ),
-        'post_production_engineer' => 
-        array (
-          'label' => 'Ingénieur·e Post-Production',
-          'description' => 'L\'ingénieur postproduction évalue les technologies audio et leur application en ce qui concerne les étapes finales de production et de publication.',
-          'example' => 'Dick Wound pour « Queens Next Door »',
-        ),
-      ),
-    ),
-    'audio_post_production' => 
-    array (
-      'label' => 'Post-Production Audio',
-      'roles' => 
-      array (
-        'audio_editor' => 
-        array (
-          'label' => 'Mont·eur·euse Son',
-          'description' => 'L\'éditeur audio coupe et réorganise l\'audio à des fins de clarté et de narration. L\'éditeur audio peut également effectuer un traitement et un mastering audio généraux.',
-          'example' => '',
-        ),
-        'sound_designer' => 
-        array (
-          'label' => 'Concept·eur·rice Sonore',
-          'description' => 'Le Sound Designer crée et compose une variété d\'éléments audio. Ces éléments sont pour la plupart secondaires à la parole, mais un Sound Designer peut éditer / produire des éléments de discours de manière créative d\'une manière artistique.',
-          'example' => '',
-        ),
-        'foley_artist' => 
-        array (
-          'label' => 'Illustrat·eur·rice Sonore',
-          'description' => 'Les effets sonores de l\'artiste Foley pour un podcast et peuvent le faire à la fois via un enregistrement physique et un traitement numérique, ou une combinaison des deux.',
-          'example' => '',
-        ),
-        'composer' => 
-        array (
-          'label' => 'Composit·eur·rice',
-          'description' => 'Le compositeur écrit une pièce musicale originale (ou plusieurs) qui est jouée sur l\'épisode publié. Le compositeur sera également souvent l\'interprète de ladite pièce musicale.',
-          'example' => 'Marcus Thorne Bagala de « This American Life »',
-        ),
-        'theme_music' => 
-        array (
-          'label' => 'Musique de Générique',
-          'description' => 'Theme Music est une pièce musicale qui accompagne le podcast à travers plusieurs épisodes, le plus souvent au début d\'un épisode. Le thème Musique est utilisé pour présenter le podcast en tant que marque. Ce rôle est pour le créateur de la musique du thème.',
-          'example' => 'Mark Philips de « Startup Podcast »',
-        ),
-        'music_production' => 
-        array (
-          'label' => 'Production Musicale',
-          'description' => 'Le rôle de production musicale aide à créer de manière créative de la musique dans un rôle distinct de l\'écriture de ladite musique. La production musicale implique souvent des décisions créatives en fonction de la méthode d\'enregistrement de la musique, de l\'arrangement des instruments, de l\'utilisation d\'effets, etc.',
-          'example' => 'Storm Duper pour « Faking Star Wars Radio »',
-        ),
-        'music_contributor' => 
-        array (
-          'label' => 'Contribution Musicale',
-          'description' => 'The Music Contributor est le créateur de la musique qui a été utilisée pour le podcast mais pas nécessairement produite spécifiquement pour le podcast. Souvent, un podcast utilisera une pièce musicale existante et créditera le créateur original.',
-          'example' => 'Bobby Lord de « Startup Podcast »',
-        ),
-      ),
-    ),
-    'administration' => 
-    array (
-      'label' => 'Gestion',
-      'roles' => 
-      array (
-        'production_coordinator' => 
-        array (
-          'label' => 'Coordinat·eur·rice de Production',
-          'description' => 'Le coordonnateur de la production est responsable de la gestion de la logistique du processus de production de l\'enregistrement à la publication, y compris l\'obtention des autorisations et des permis requis, la connexion des différentes équipes de production et d\'enregistrement, la coordination de la création des métadonnées de post-production, la budgétisation, etc.',
-          'example' => 'Taneya Boyde sur « Prêt pour le changement ? »',
-        ),
-        'booking_coordinator' => 
-        array (
-          'label' => 'Programmat·eur·rice',
-          'description' => 'Le coordonnateur des réservations est chargé de faire venir de nouveaux invités pour les entrevues, y compris la recherche des invités, la planification des entrevues, le matériel d\'accueil et les processus post-publication.',
-          'example' => 'Meryl Klemow pour « Campfire Sht Show »',
-        ),
-        'production_assistant' => 
-        array (
-          'label' => 'Assistant·e de Production',
-          'description' => 'L\'assistant de production aide à soutenir un membre de la direction d\'un podcast (souvent un réalisateur ou un producteur), en aidant à les préparer de diverses manières, y compris la planification, la logistique, les communications, etc.',
-          'example' => 'Wallace Mack pour « The Nod »',
-        ),
-        'content_manager' => 
-        array (
-          'label' => 'Responsable des Contenus',
-          'description' => 'Le gestionnaire de contenu est responsable de la distribution du contenu d\'un podcast à l\'intérieur et à l\'extérieur de l\'épisode, y compris, mais sans s\'y limiter, les clips, les newsletters, les images, les promotions croisées, etc.',
-          'example' => 'Kenneth Lee Johnson II pour « Malice Corp Smack Talk »',
-        ),
-        'marketing_manager' => 
-        array (
-          'label' => 'Responsable Marketing',
-          'description' => 'Le directeur du marketing est responsable de la promotion du contenu d\'un podcast par le biais de diverses stratégies de sensibilisation telles que des campagnes sur les médias sociaux, le développement d\'une présence sur le Web, la gestion des relations publiques et des stratégies de communication et d\'autres techniques créatives pour acquérir et fidéliser les auditeurs.',
-          'example' => '',
-        ),
-        'sales_representative' => 
-        array (
-          'label' => 'Commercial·e',
-          'description' => 'Le représentant des ventes est responsable de la monétisation du contenu des balados en gérant et en vendant l\'inventaire publicitaire.',
-          'example' => '',
-        ),
-        'sales_manager' => 
-        array (
-          'label' => 'Direct·eur·rice Commercial·e',
-          'description' => 'Le directeur des ventes est responsable de tous les aspects de la monétisation des podcasts, tels que la supervision des représentants des ventes, la gestion de l\'inventaire publicitaire et la conception de stratégies de monétisation via des canaux tels que les partenariats d\'affiliation, la marchandise, les événements en direct et d\'autres stratégies de revenus.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'visuals' => 
-    array (
-      'label' => 'Illustrations',
-      'roles' => 
-      array (
-        'graphic_designer' => 
-        array (
-          'label' => 'Infographiste',
-          'description' => 'Le graphiste est quelqu\'un qui a créé des visuels personnalisés pour accompagner le podcast de différentes manières.',
-          'example' => 'Sky Knight pour « The XP Billionaires »',
-        ),
-        'cover_art_designer' => 
-        array (
-          'label' => 'Concept·eur·rice de la Couverture',
-          'description' => 'Le Cover Art Designer crée la pochette affichée d\'un podcast ou d\'un épisode. Pour plus de clarté, la pochette est l\'image principale (presque toujours carrée) accompagnant le podcast dans les répertoires, tandis que la pochette d\'épisode est affichée de la même manière au niveau de l\'épisode. Ce rôle peut être un concepteur numérique, un artiste, un photographe ou tout autre créatif visuel.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'community' => 
-    array (
-      'label' => 'Communauté',
-      'roles' => 
-      array (
-        'social_media_manager' => 
-        array (
-          'label' => 'Responsable Réseaux Sociaux',
-          'description' => 'Le gestionnaire de médias sociaux gère les comptes de médias sociaux du podcast, y compris, mais sans s\'y limiter, la création de contenu, la publication, les réponses, la surveillance, etc.',
-          'example' => 'Tom Joshi-Cale pour « World on a String »',
-        ),
-      ),
-    ),
-    'misc' => 
-    array (
-      'label' => 'Divers',
-      'roles' => 
-      array (
-        'consultant' => 
-        array (
-          'label' => 'Consultant',
-          'description' => 'Un consultant est un poste de tiers où une personne extérieure à l\'organisation travaille sur un projet, offrant souvent une expertise spécifique. Il s\'agit d\'un rôle de modificateur et peut être appliqué à n\'importe quelle zone de travail.',
-          'example' => 'Ross Wilcock pour «Being Kenzie-Feature Long Immersive Horror»',
-        ),
-        'intern' => 
-        array (
-          'label' => 'Stagiaire',
-          'description' => 'Un stagiaire est un poste d\'apprenti où quelqu\'un travaille pendant un temps limité au sein d\'une organisation pour acquérir une expérience de travail dans un domaine spécifique. Il s\'agit d\'un rôle de modificateur et peut être appliqué à n\'importe quelle zone de travail.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'video_production' => 
-    array (
-      'label' => 'Production Vidéo',
-      'roles' => 
-      array (
-        'camera_operator' => 
-        array (
-          'label' => 'Cadr·eur·euse',
-          'description' => 'Un caméraman est chargé de capturer et d\'enregistrer tous les aspects d\'une scène pour le cinéma et la télévision. Ils doivent comprendre les aspects techniques du fonctionnement d\'une caméra, cadrer une photo appropriée en ce qui concerne l\'éclairage et la mise en scène, mettre au point l\'objectif et avoir un œil visuel pour obtenir un look spécifique.',
-          'example' => '',
-        ),
-        'lighting_designer' => 
-        array (
-          'label' => 'Concept·eur·rice Lumières',
-          'description' => 'Un concepteur d\'éclairage travaille avec le DP et le directeur pour créer un aspect et une sensation spécifiques d\'une scène en utilisant diverses techniques d\'éclairage. Ils doivent être capables d\'interpréter la direction créative et de lui donner vie.',
-          'example' => '',
-        ),
-        'camera_grip' => 
-        array (
-          'label' => 'Machiniste',
-          'description' => 'Une poignée d\'appareil photo est responsable de la construction et de l\'entretien de toutes les pièces d\'un appareil photo et de ses accessoires tels que les trépieds, les grues, les chariots, etc.',
-          'example' => '',
-        ),
-        'assistant_camera' => 
-        array (
-          'label' => 'Cadr·eur·euse Assistant·e',
-          'description' => '1st AC est responsable de l\'équipement de la caméra, de la construction des caméras avant le début de chaque journée, de l\'organisation de toutes les pièces et des divers accessoires, du remplacement des objectifs si nécessaire et également de la mise au point pour les opérateurs DP et caméra. Le CA terminera également chaque journée en nettoyant les appareils photo, en écrivant des notes sur l\'appareil photo, en marquant les cartes multimédias et en les remettant au DIT.',
-          'example' => '',
-        ),
-      ),
-    ),
-    'video_post_production' => 
-    array (
-      'label' => 'Post-Production Vidéo',
-      'roles' => 
-      array (
-        'editor' => 
-        array (
-          'label' => 'Rédact·eur·rice en Chef·fe',
-          'description' => 'Les éditeurs de télévision sont chargés de prendre les séquences et les clips et de les mélanger pour créer la vision et la narration du réalisateur.',
-          'example' => '',
-        ),
-        'assistant_editor' => 
-        array (
-          'label' => 'Rédact·eur·rice en Chef·fe Adjoint·e',
-          'description' => 'L\'assistant de montage est chargé de prendre les médias de l\'ensemble, de les intégrer dans le logiciel de montage désigné et d\'organiser les images de manière efficace pour l\'éditeur. Ils doivent également faire très attention pour s\'assurer que l\'audio et la vidéo sont synchronisés et que toutes les séquences du plateau sont correctement ingérées.',
-          'example' => '',
-        ),
-      ),
-    ),
-  ),
-);
diff --git a/modules/Admin/Language/fr/Platforms.php b/modules/Admin/Language/fr/Platforms.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a5a59c4d45b2c10e0a264e5f33db6bb2e1e9922
--- /dev/null
+++ b/modules/Admin/Language/fr/Platforms.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'title' => 'Plateformes',
+    'home_url' => 'Aller au site {platformName}',
+    'submit_url' => 'Soumettez votre podcast sur {platformName}',
+    'visible' => 'Afficher sur la page d’accueil du podcast ?',
+    'on_embeddable_player' => 'Afficher sur le lecteur intégré ?',
+    'remove' => 'Supprimer {platformName}',
+    'submit' => 'Enregistrer',
+    'messages' => [
+        'updateSuccess' => 'Les liens ont été enregistrés avec succès !',
+        'removeLinkSuccess' => 'Le lien a été supprimé.',
+        'removeLinkError' =>
+            'Le lien n’a pas pu être supprimé. Merci d’essayer à nouveau.',
+    ],
+    'description' => [
+        'podcasting' => 'L’identifiant du podcast sur cette plate-forme',
+        'social' => 'L’identifiant du compte du podcast sur cette plate-forme',
+        'funding' => 'Message d’incitation à l’action',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Podcast.php b/modules/Admin/Language/fr/Podcast.php
new file mode 100644
index 0000000000000000000000000000000000000000..1ef22cd7767298458d81d26c995b43a835c4b378
--- /dev/null
+++ b/modules/Admin/Language/fr/Podcast.php
@@ -0,0 +1,239 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'all_podcasts' => 'Tous les podcasts',
+    'no_podcast' => 'Aucun podcast trouvé !',
+    'create' => 'Créer un podcast',
+    'import' => 'Importer un podcast',
+    'new_episode' => 'Créer un épisode',
+    'feed' => 'RSS',
+    'view' => 'Voir le podcast',
+    'edit' => 'Modifier le podcast',
+    'delete' => 'Supprimer le podcast',
+    'see_episodes' => 'Voir les épisodes',
+    'see_contributors' => 'Voir les contributeurs',
+    'go_to_page' => 'Aller à la page',
+    'latest_episodes' => 'Derniers épisodes',
+    'see_all_episodes' => 'Voir tous les épisodes',
+    'form' => [
+        'identity_section_title' => 'Informations sur le Podcast',
+        'identity_section_subtitle' =>
+            'Ces champs vous permettent de vous faire remarquer.',
+        'image' => 'Image de couverture',
+        'title' => 'Titre',
+        'handle' => 'Identifiant',
+        'handle_hint' =>
+            'Utilisé pour identifier le podcast. Les majuscules, les minuscules, les chiffres et le caractère souligné « _ » sont acceptés.',
+        'type' => [
+            'label' => 'Type',
+            'hint' =>
+                '- <strong>épisodique</strong> : si les épisodes sont destinés à être consommés sans ordre spécifique. Les épisodes les plus récents seront présentés en premier.<br/>- <strong>série</strong>: si les épisodes sont destinés à être consommés dans un ordre séquentiel bien défini. Les épisodes les plus anciens seront présentés en premier.',
+            'episodic' => 'Épisodique',
+            'serial' => 'Série',
+        ],
+        'description' => 'Description',
+        'classification_section_title' => 'Classification',
+        'classification_section_subtitle' =>
+            'Ces champs auront un impact sur votre audience et votre concurrence.',
+        'language' => 'Langue',
+        'category' => 'Catégorie',
+        'category_placeholder' => 'Sélectionner une catégorie…',
+        'other_categories' => 'Autres catégories',
+        'parental_advisory' => [
+            'label' => 'Avertissement parental',
+            'hint' => 'Contient-il un contenu explicite ?',
+            'undefined' => 'non défini',
+            'clean' => 'Convenable',
+            'explicit' => 'Explicite',
+        ],
+        'author_section_title' => 'Auteur / Autrice',
+        'author_section_subtitle' => 'Qui gère le podcast ?',
+        'owner_name' => 'Nom du/de la propriétaire',
+        'owner_name_hint' =>
+            'Pour usage administratif uniquement. Visible dans le flux RSS public.',
+        'owner_email' => 'E-mail du/de la propriétaire',
+        'owner_email_hint' =>
+            'Utilisé par la plupart des plateformes pour vérifier la propriété du podcast. Visible dans le flux RSS public.',
+        'publisher' => 'Éditeur / Éditrice',
+        'publisher_hint' =>
+            'Le groupe responsable de la création du podcast. Fait souvent référence à la société mère ou au réseau d’un podcast. Ce champ est parfois appelé « Auteur ».',
+        'copyright' => 'Droit d’auteur',
+        'location_section_title' => 'Localisation',
+        'location_section_subtitle' => 'De quel lieu ce podcast parle-t-il ?',
+        'location_name' => 'Nom ou adresse du lieu',
+        'location_name_hint' => 'Ce lieu peut être réel ou fictif',
+        'monetization_section_title' => 'Monétisation',
+        'monetization_section_subtitle' =>
+            'Gagnez de l’argent grâce à votre audience.',
+        'payment_pointer' =>
+            'Adresse de paiement (Payment Pointer) pour Web Monetization',
+        'payment_pointer_hint' =>
+            'L’adresse où vous recevrez de l’argent grâce à Web Monetization',
+        'advanced_section_title' => 'Paramètres avancés',
+        'advanced_section_subtitle' =>
+            'Si vous avez besoin d’une balise que nous n’avons pas couverte, définissez-la ici.',
+        'custom_rss' => 'Balises RSS personnalisées pour le podcast',
+        'custom_rss_hint' => 'Ceci sera injecté dans la balise ❬channel❭.',
+        'partnership' => 'Partenariat',
+        'partner_id' => 'ID',
+        'partner_link_url' => 'URL lien',
+        'partner_image_url' => 'URL image',
+        'partner_id_hint' => 'Votre identifiant personnel partenaire',
+        'partner_link_url_hint' => 'L’adresse générique des liens partenaire',
+        'partner_image_url_hint' => 'L’adresse générique des images partenaire',
+        'status_section_title' => 'Statut',
+        'status_section_subtitle' => 'Vivant ou mort ?',
+        'block' => 'Le podcast doit être masqué sur toutes les plateformes',
+        'complete' => 'Le podcast n’aura plus de nouveaux épisodes.',
+        'lock' => 'Empêcher la copie du podcast',
+        'lock_hint' =>
+            'Le but est d’indiquer aux autres plates-formes de podcast si elles sont autorisées à importer ce flux. La valeur « oui » signifie que toute tentative d’importation de ce flux dans une nouvelle plateforme doit être rejetée.',
+        'submit_create' => 'Créer le podcast',
+        'submit_edit' => 'Enregistrer le podcast',
+    ],
+    'category_options' => [
+        'uncategorized' => 'non catégorisé',
+        'arts' => 'Arts',
+        'business' => 'Entreprise',
+        'comedy' => 'Comédie',
+        'education' => 'Éducation',
+        'fiction' => 'Fiction',
+        'government' => 'Gouvernement',
+        'health_and_fitness' => 'Santé et remise en forme',
+        'history' => 'Histoire',
+        'kids_and_family' => 'Enfants et famille',
+        'Leisure' => 'Loisirs',
+        'music' => 'Musique',
+        'news' => 'Actualités',
+        'religion_and_spirituality' => 'Religion et spiritualité',
+        'science' => 'Science',
+        'society_and_culture' => 'Société et Culture',
+        'sports' => 'Sports',
+        'technology' => 'Technologie',
+        'true_crime' => 'Documentaire criminel',
+        'tv_and_film' => 'Télévision et films',
+        'books' => 'Livres',
+        'design' => 'Design',
+        'fashion_and_beauty' => 'Mode et beauté',
+        'food' => 'Nourriture',
+        'performing_arts' => 'Arts du spectacle',
+        'visual_arts' => 'Arts visuels',
+        'careers' => 'Carrières',
+        'entrepreneurship' => 'Entrepreneuriat',
+        'investment' => 'Investissement',
+        'management' => 'Gestion',
+        'marketing' => 'Marketing',
+        'non_profit' => 'À but non lucratif',
+        'comedy_interviews' => 'Entretiens comiques',
+        'improv' => 'Improvisation',
+        'stand_up' => 'Stand up',
+        'courses' => 'Cours',
+        'how_to' => 'Tutoriels',
+        'language_learning' => 'Apprentissage des langues',
+        'self_improvement' => 'Développement personnel',
+        'comedy_fiction' => 'Comédie Fiction',
+        'drama' => 'Drame',
+        'science_fiction' => 'Science Fiction',
+        'alternative_health' => 'Santé alternative',
+        'fitness' => 'Remise en forme',
+        'medicine' => 'Médecine',
+        'mental_health' => 'Santé mentale',
+        'nutrition' => 'Nutrition',
+        'sexuality' => 'Sexualité',
+        'education_for_kids' => 'Éducation pour les enfants',
+        'parenting' => 'Parentalité',
+        'pets_and_animals' => 'Animaux de compagnie et animaux',
+        'stories_for_kids' => 'Histoires pour enfants',
+        'animation_and_manga' => 'Animation et Manga',
+        'Automotive' => 'Automobile',
+        'aviation' => 'Aviation',
+        'craft' => 'Artisanat',
+        'games' => 'Jeux',
+        'hobbies' => 'Loisirs',
+        'home_and_garden' => 'Maison et jardin',
+        'video_games' => 'Jeux vidéo',
+        'music_commentary' => 'Commentaire musical',
+        'music_history' => 'Histoire de la musique',
+        'music_interviews' => 'Entretiens musicaux',
+        'business_news' => 'Actualités économiques',
+        'daily_news' => 'Actualités quotidiennes',
+        'entertainment_news' => 'Actualités du divertissement',
+        'news_commentary' => 'Commentaire d’actualité',
+        'politique' => 'Politique',
+        'sports_news' => 'Actualités sportives',
+        'tech_news' => 'Actualités techniques',
+        'buddhism' => 'Bouddhisme',
+        'christianity' => 'Christianisme',
+        'hinduism' => 'Hindouisme',
+        'islam' => 'Islam',
+        'judaism' => 'Judaïsme',
+        'religion' => 'Religion',
+        'spiritualité' => 'Spiritualité',
+        'astronomy' => 'Astronomie',
+        'chemistry' => 'Chimie',
+        'earth_sciences' => 'Sciences de la Terre',
+        'life_sciences' => 'Sciences de la vie',
+        'Mathématiques' => 'Mathématiques',
+        'natural_sciences' => 'Sciences naturelles',
+        'nature' => 'Nature',
+        'physics' => 'Physique',
+        'social_sciences' => 'Sciences sociales',
+        'documentary' => 'Documentaire',
+        'personal_journals' => 'Journaux personnels',
+        'philosophie' => 'Philosophie',
+        'places_and_travel' => 'Lieux et voyages',
+        'relations' => 'Relations',
+        'baseball' => 'Baseball',
+        'basketball' => 'Basketball',
+        'cricket' => 'Cricket',
+        'fantasy_sports' => 'Sports fantastiques',
+        'football' => 'Football',
+        'golf' => 'Golf',
+        'hockey' => 'Hockey',
+        'rugby' => 'Rugby',
+        'running' => 'Course',
+        'soccer' => 'Football',
+        'swimming' => 'Natation',
+        'tennis' => 'Tennis',
+        'volleyball' => 'Volleyball',
+        'wilderness' => 'Naturalité',
+        'wrestling' => 'Lutte',
+        'after_shows' => 'Après spectacle',
+        'film_history' => 'Histoire du cinéma',
+        'film_interviews' => 'Entretiens de films',
+        'film_reviews' => 'Critiques de films',
+        'tv_reviews' => 'Critiques TV',
+    ],
+    'by' => 'Par {publisher}',
+    'season' => 'Saison {seasonNumber}',
+    'list_of_episodes_year' => 'Épisodes de {year} ({episodeCount})',
+    'list_of_episodes_season' =>
+        'Épisodes de la saison {seasonNumber} ({episodeCount})',
+    'no_episode' => 'Aucun épisode trouvé !',
+    'no_episode_hint' =>
+        'Naviguez au sein des épisodes du podcast episodes grâce à la barre de navigation ci-dessus.',
+    'follow' => 'Suivre',
+    'followers' => '{numberOfFollowers, plural,
+        one {<span class="font-semibold">#</span> abonné·e}
+        other {<span class="font-semibold">#</span> abonné·e·s}
+    }',
+    'posts' => '{numberOfPosts, plural,
+        one {<span class="font-semibold">#</span> publication}
+        other {<span class="font-semibold">#</span> publications}
+    }',
+    'activity' => 'Activité',
+    'episodes' => 'Épisodes',
+    'sponsor_title' => 'Vous aimez le podcast ?',
+    'sponsor' => 'Soutenez-nous',
+    'funding_links' => 'Liens de financement pour {podcastTitle}',
+    'find_on' => 'Trouvez {podcastTitle} sur',
+    'listen_on' => 'Écoutez sur',
+];
diff --git a/modules/Admin/Language/fr/PodcastImport.php b/modules/Admin/Language/fr/PodcastImport.php
new file mode 100644
index 0000000000000000000000000000000000000000..94b3167df4ae56c795cc63392572d7cd1441d887
--- /dev/null
+++ b/modules/Admin/Language/fr/PodcastImport.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'warning' =>
+        'Cette procédure peut prendre du temps.<br/>Dans la mesure où la version actuelle ne montre aucune progression durant l’exécution, vous ne pourrez voir aucun changement avant la fin.<br/>En cas d’erreur de timeout,  augmentez la valeur de `max_execution_time`.',
+    'old_podcast_section_title' => 'Le podcast à importer',
+    'old_podcast_section_subtitle' =>
+        'Assurez-vous d’être détenteur des droits du podcast avant de l’importer. Copier et diffuser un podcast sans en détenir les droits est assimilable à de la contrefaçon et est passible de poursuites.',
+    'imported_feed_url' => 'Adresse du flux',
+    'imported_feed_url_hint' => 'Le flux doit être au format xml ou rss.',
+    'new_podcast_section_title' => 'Le nouveau podcast',
+    'advanced_params_section_title' => 'Paramètres avancés',
+    'advanced_params_section_subtitle' =>
+        'Si vous ne savez pas à quoi servent ces champs, conservez les valeurs par défaut.',
+    'slug_field' => [
+        'label' =>
+            'Quel champ utiliser pour calculer l’identifiant de l’épisode',
+        'link' => '&lt;link&gt; (adresse)',
+        'title' => '&lt;title&gt; (titre)',
+    ],
+    'description_field' => 'Champs pour la description des épisodes',
+    'force_renumber' => 'Forcer la re-numérotation des épisodes',
+    'force_renumber_hint' =>
+        'Utilisez ceci si le podcast à importer ne contient pas de numéros d’épisodes mais que vous souhaitez en ajouter pendant l’import.',
+    'season_number' => 'Numéro de saison',
+    'season_number_hint' =>
+        'Utilisez ceci si le podcast à importer ne contient pas de numéros de saison mais que vous souhaitez en définir un. Laissez vide sinon.',
+    'max_episodes' => 'Nombre maximum d’épisodes à importer',
+    'max_episodes_hint' => 'Laissez vide pour importer tous les épisodes',
+    'lock_import' =>
+        'Ce flux est protégé. Vous ne pouvez pas l’importer. Si en vous êtes le propriétaire, déprotégez-le sur la plate-forme d’origine.',
+    'submit' => 'Importer le podcast',
+];
diff --git a/modules/Admin/Language/fr/PodcastNavigation.php b/modules/Admin/Language/fr/PodcastNavigation.php
new file mode 100644
index 0000000000000000000000000000000000000000..69397254326ed9ab47594424d54e184145ccc2c8
--- /dev/null
+++ b/modules/Admin/Language/fr/PodcastNavigation.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'go_to_page' => 'Aller à la page du podcast',
+    'dashboard' => 'Tableau de bord du podcast',
+    'podcast-view' => 'Accueil',
+    'podcast-edit' => 'Modifier le podcast',
+    'episodes' => 'Épisodes',
+    'episode-list' => 'Tous les épisodes',
+    'episode-create' => 'Créer un épisode',
+    'fediverse' => 'Fédiverse',
+    'fediverse-block_lists' => 'Listes de blocage',
+    'analytics' => 'Mesures d’audience',
+    'persons' => 'Intervenants',
+    'podcast-person-manage' => 'Gestion des intervenants',
+    'contributors' => 'Contributeurs',
+    'contributor-list' => 'Tous les contributeurs',
+    'contributor-add' => 'Ajouter un contributeur',
+    'platforms' => 'Plate-formes externes',
+    'platforms-podcasting' => 'Podcasts',
+    'platforms-social' => 'Réseaux Sociaux',
+    'platforms-funding' => 'Financement',
+    'podcast-analytics' => 'Vue d’ensemble',
+    'podcast-analytics-webpages' => 'Visites des pages web',
+    'podcast-analytics-locations' => 'Localisations',
+    'podcast-analytics-unique-listeners' => 'Auditeurs uniques',
+    'podcast-analytics-players' => 'Lecteurs',
+    'podcast-analytics-listening-time' => 'Durée d’écoute',
+    'podcast-analytics-time-periods' => 'Périodes',
+];
diff --git a/modules/Admin/Language/fr/User.php b/modules/Admin/Language/fr/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..ccb262b15853d1e3434875226850a786277c06c8
--- /dev/null
+++ b/modules/Admin/Language/fr/User.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => 'Modifier les rôles de {username}',
+    'forcePassReset' => 'Forcer la réinitialisation du mot de passe',
+    'ban' => 'Bloquer',
+    'unban' => 'Débloquer',
+    'delete' => 'Supprimer',
+    'create' => 'Créer un utilisateur',
+    'view' => 'Informations de {username}',
+    'all_users' => 'Tous les utilisateurs',
+    'list' => [
+        'user' => 'Utilisateurs',
+        'roles' => 'Rôles',
+        'banned' => 'Bloqué ?',
+    ],
+    'form' => [
+        'email' => 'E-mail',
+        'username' => 'Identifiant',
+        'password' => 'Mot de passe',
+        'new_password' => 'Nouveau mot de passe',
+        'roles' => 'Rôles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Créer un utilisateur',
+        'submit_edit' => 'Enregistrer',
+        'submit_password_change' => 'Valider !',
+    ],
+    'roles' => [
+        'superadmin' => 'Super-utilisateur',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'Utilisateur créé avec succès ! {username} devra modifier son mot de passe à la première authentification.',
+        'rolesEditSuccess' =>
+            'Les rôles de {username} ont été mis à jour avec succès.',
+        'forcePassResetSuccess' =>
+            '{username} devra modifier son mot de passe à la prochaine visite.',
+        'banSuccess' => '{username} a été bloqué.',
+        'unbanSuccess' => '{username} a été débloqué.',
+        'banSuperAdminError' =>
+            '{username} est un super-utilisateur, on ne bloque pas un super-utilisateur comme ça…',
+        'deleteSuperAdminError' =>
+            '{username} est un super-utilisateur, on ne supprime pas un super-utilisateur comme ça…',
+        'deleteSuccess' => '{username} a été supprimé.',
+    ],
+];
diff --git a/modules/Admin/Language/fr/Validation.php b/modules/Admin/Language/fr/Validation.php
new file mode 100644
index 0000000000000000000000000000000000000000..12b45319f92a4522f54f9c0bdba3ec39dbe8a00c
--- /dev/null
+++ b/modules/Admin/Language/fr/Validation.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'min_dims' =>
+        '{field} n’est pas une image ou n’a pas la taille minimale requise.',
+    'is_image_squared' =>
+        '{field} n’est pas une image ou n’est pas carré (largeur et hauteur différentes).',
+    'validate_url' =>
+        'Le champs {field} doit être une adresse valide (par exemple https://exemple.com/).',
+];
diff --git a/app/Views/admin/_layout.php b/modules/Admin/Views/_layout.php
similarity index 95%
rename from app/Views/admin/_layout.php
rename to modules/Admin/Views/_layout.php
index 16bcf98339400eeeeec398043da20de2cb22d701..1232eb903e18e7970d89dccac376d382a2e66d50 100644
--- a/app/Views/admin/_layout.php
+++ b/modules/Admin/Views/_layout.php
@@ -17,9 +17,9 @@
     <div id="sidebar-backdrop" role="button" tabIndex="0" aria-label="Close" class="fixed z-50 hidden w-full h-full bg-gray-900 bg-opacity-50 md:hidden"></div>
     <aside id="admin-sidebar" class="sticky top-0 z-50 flex flex-col max-h-screen transition duration-200 ease-in-out transform -translate-x-full bg-white border-r w-80 holy-grail-sidebar md:translate-x-0">
         <?php if (isset($podcast)): ?>
-            <?= $this->include('admin/podcast/_sidebar') ?>
+            <?= $this->include('Modules\Admin\Views\podcast\_sidebar') ?>
         <?php else: ?>
-            <?= $this->include('admin/_sidebar') ?>
+            <?= $this->include('Modules\Admin\Views\_sidebar') ?>
         <?php endif; ?>
     </aside>
     <main class="holy-grail-main">
diff --git a/app/Views/admin/_partials/_user_info.php b/modules/Admin/Views/_partials/_user_info.php
similarity index 100%
rename from app/Views/admin/_partials/_user_info.php
rename to modules/Admin/Views/_partials/_user_info.php
diff --git a/app/Views/admin/_sidebar.php b/modules/Admin/Views/_sidebar.php
similarity index 100%
rename from app/Views/admin/_sidebar.php
rename to modules/Admin/Views/_sidebar.php
diff --git a/app/Views/admin/contributor/add.php b/modules/Admin/Views/contributor/add.php
similarity index 95%
rename from app/Views/admin/contributor/add.php
rename to modules/Admin/Views/contributor/add.php
index fbe53d9adf56957ab94c42eb7b6fb89ebef8b35c..cfaacfa4cf59919c2ae3a93312fb58b1c3082495 100644
--- a/app/Views/admin/contributor/add.php
+++ b/modules/Admin/Views/contributor/add.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Contributor.add_contributor', [$podcast->title]) ?>
diff --git a/app/Views/admin/contributor/edit.php b/modules/Admin/Views/contributor/edit.php
similarity index 94%
rename from app/Views/admin/contributor/edit.php
rename to modules/Admin/Views/contributor/edit.php
index 221e9fb6b36186b417fd0c10309b6a01d627ad51..70c6602b62a1a19f55c40872b15aa6504e28001a 100644
--- a/app/Views/admin/contributor/edit.php
+++ b/modules/Admin/Views/contributor/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Contributor.edit_role', [$user->username]) ?>
diff --git a/app/Views/admin/contributor/list.php b/modules/Admin/Views/contributor/list.php
similarity index 97%
rename from app/Views/admin/contributor/list.php
rename to modules/Admin/Views/contributor/list.php
index 5f182df181b0dd40bfc529d2fe7035d090fddd5b..60d1c18a1104329cee58386c730f501d250ee012 100644
--- a/app/Views/admin/contributor/list.php
+++ b/modules/Admin/Views/contributor/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Contributor.podcast_contributors') ?>
diff --git a/app/Views/admin/contributor/view.php b/modules/Admin/Views/contributor/view.php
similarity index 94%
rename from app/Views/admin/contributor/view.php
rename to modules/Admin/Views/contributor/view.php
index e7f6545e8fbc7f74fb531d8776337cecda239a96..98bcb1960188590dcdd1ef63d723c0f24e52179c 100644
--- a/app/Views/admin/contributor/view.php
+++ b/modules/Admin/Views/contributor/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Contributor.view', [
diff --git a/app/Views/admin/dashboard.php b/modules/Admin/Views/dashboard.php
similarity index 85%
rename from app/Views/admin/dashboard.php
rename to modules/Admin/Views/dashboard.php
index 58bc0229f5d74a3bc18ed3b872fe4806ea74da5e..3db6af697fefd0faa8060cc616b9944213ecf335 100644
--- a/app/Views/admin/dashboard.php
+++ b/modules/Admin/Views/dashboard.php
@@ -1,5 +1,5 @@
 <?= helper('components') ?>
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Admin.dashboard') ?>
diff --git a/app/Views/admin/episode/create.php b/modules/Admin/Views/episode/create.php
similarity index 99%
rename from app/Views/admin/episode/create.php
rename to modules/Admin/Views/episode/create.php
index c5b885f347191690041f59eaa71ef2fcc7932b85..6fde6539badd8cefb6ee0c273bee8e26490428d4 100644
--- a/app/Views/admin/episode/create.php
+++ b/modules/Admin/Views/episode/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.create') ?>
diff --git a/app/Views/admin/episode/edit.php b/modules/Admin/Views/episode/edit.php
similarity index 99%
rename from app/Views/admin/episode/edit.php
rename to modules/Admin/Views/episode/edit.php
index 863d8cfaaa28a267e23fc5b36a856f738076d1b7..aba8c96bd0305475aa810c1dec37c9133b120adb 100644
--- a/app/Views/admin/episode/edit.php
+++ b/modules/Admin/Views/episode/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.edit') ?>
diff --git a/app/Views/admin/episode/embeddable_player.php b/modules/Admin/Views/episode/embeddable_player.php
similarity index 97%
rename from app/Views/admin/episode/embeddable_player.php
rename to modules/Admin/Views/episode/embeddable_player.php
index 4cadb490c6220314a781ab3f818369c0f84cc744..7738c1f3429539eb82124a249bb4ffca9bc96c80 100644
--- a/app/Views/admin/episode/embeddable_player.php
+++ b/modules/Admin/Views/episode/embeddable_player.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.embeddable_player.title') ?>
diff --git a/app/Views/admin/episode/list.php b/modules/Admin/Views/episode/list.php
similarity index 99%
rename from app/Views/admin/episode/list.php
rename to modules/Admin/Views/episode/list.php
index c12c39b06793b97f003851f7da698ce327ada2b8..b67f42ebb5335a93d72d9a809b56c0369290fa31 100644
--- a/app/Views/admin/episode/list.php
+++ b/modules/Admin/Views/episode/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.all_podcast_episodes') ?>
diff --git a/app/Views/admin/episode/persons.php b/modules/Admin/Views/episode/persons.php
similarity index 98%
rename from app/Views/admin/episode/persons.php
rename to modules/Admin/Views/episode/persons.php
index d2e1dc910f46b7dc29fcbdbf95af889eb5f4acd4..fbf0c022019118c9cd5b2ac95bde914c0161e6fc 100644
--- a/app/Views/admin/episode/persons.php
+++ b/modules/Admin/Views/episode/persons.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Person.episode_form.title') ?>
diff --git a/app/Views/admin/episode/publish.php b/modules/Admin/Views/episode/publish.php
similarity index 99%
rename from app/Views/admin/episode/publish.php
rename to modules/Admin/Views/episode/publish.php
index 238d3b964d998d527b6f12b6e5b88f882b6e4d03..9804b2cd42b2f532d3dc389f0d7ea84b421a15d1 100644
--- a/app/Views/admin/episode/publish.php
+++ b/modules/Admin/Views/episode/publish.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.publish') ?>
diff --git a/app/Views/admin/episode/publish_edit.php b/modules/Admin/Views/episode/publish_edit.php
similarity index 99%
rename from app/Views/admin/episode/publish_edit.php
rename to modules/Admin/Views/episode/publish_edit.php
index 8e18531bfc33600d0283d5b326a90144edfe2122..d093ac909925f691b6827ec20eec58a2d990e277 100644
--- a/app/Views/admin/episode/publish_edit.php
+++ b/modules/Admin/Views/episode/publish_edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.publish_edit') ?>
diff --git a/app/Views/admin/episode/soundbites.php b/modules/Admin/Views/episode/soundbites.php
similarity index 99%
rename from app/Views/admin/episode/soundbites.php
rename to modules/Admin/Views/episode/soundbites.php
index dc1c23629c722b5154be6094de6f88a7b45865b4..dd97aecb441446c25ac3ca7b6f7a8949aac959e3 100644
--- a/app/Views/admin/episode/soundbites.php
+++ b/modules/Admin/Views/episode/soundbites.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.soundbites_form.title') ?>
diff --git a/app/Views/admin/episode/unpublish.php b/modules/Admin/Views/episode/unpublish.php
similarity index 96%
rename from app/Views/admin/episode/unpublish.php
rename to modules/Admin/Views/episode/unpublish.php
index 64f1c465bb40175ed104fe5df51d0a67a77d1b71..3deeb58331e8636f51d1c0c01b3d25b52c23782a 100644
--- a/app/Views/admin/episode/unpublish.php
+++ b/modules/Admin/Views/episode/unpublish.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Episode.unpublish') ?>
diff --git a/app/Views/admin/episode/view.php b/modules/Admin/Views/episode/view.php
similarity index 98%
rename from app/Views/admin/episode/view.php
rename to modules/Admin/Views/episode/view.php
index 87f451bfb40daa82e826f117baa3ea80e34e305d..a4f207c8079c3eb05875a911a3b16dccd7dfc887 100644
--- a/app/Views/admin/episode/view.php
+++ b/modules/Admin/Views/episode/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $episode->title ?>
diff --git a/app/Views/admin/fediverse/blocked_actors.php b/modules/Admin/Views/fediverse/blocked_actors.php
similarity index 97%
rename from app/Views/admin/fediverse/blocked_actors.php
rename to modules/Admin/Views/fediverse/blocked_actors.php
index 0cc8ba5aad274530c644df2fa4bd8a73a5f3f52a..5d4438e166745590843c849c9abe072a8f40384a 100644
--- a/app/Views/admin/fediverse/blocked_actors.php
+++ b/modules/Admin/Views/fediverse/blocked_actors.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Fediverse.blocked_actors') ?>
diff --git a/app/Views/admin/fediverse/blocked_domains.php b/modules/Admin/Views/fediverse/blocked_domains.php
similarity index 97%
rename from app/Views/admin/fediverse/blocked_domains.php
rename to modules/Admin/Views/fediverse/blocked_domains.php
index 713b737b31bb2f225cb9ac42392fac2d3f5e526d..cdf9df4f5d74c5055a4aee41a6c4842f1fede482 100644
--- a/app/Views/admin/fediverse/blocked_domains.php
+++ b/modules/Admin/Views/fediverse/blocked_domains.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Fediverse.blocked_domains') ?>
diff --git a/app/Views/admin/my_account/change_password.php b/modules/Admin/Views/my_account/change_password.php
similarity index 95%
rename from app/Views/admin/my_account/change_password.php
rename to modules/Admin/Views/my_account/change_password.php
index 6af3e6d5f9c6b828d374f4748664cc043b6d344b..7cf61b6f564982474a876d017da59188707985b2 100644
--- a/app/Views/admin/my_account/change_password.php
+++ b/modules/Admin/Views/my_account/change_password.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('MyAccount.changePassword') ?>
diff --git a/app/Views/admin/my_account/view.php b/modules/Admin/Views/my_account/view.php
similarity index 65%
rename from app/Views/admin/my_account/view.php
rename to modules/Admin/Views/my_account/view.php
index 77d30922192e2935fed8a9bb12e3a5e86b08d5ff..f497adacfd5dd3bc2d7b754b0439ad869e203119 100644
--- a/app/Views/admin/my_account/view.php
+++ b/modules/Admin/Views/my_account/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('MyAccount.info') ?>
@@ -11,6 +11,6 @@
 
 <?= $this->section('content') ?>
 
-<?= view('admin/_partials/_user_info.php', ['user' => user()]) ?>
+<?= view('Modules\Admin\Views\_partials/_user_info.php', ['user' => user()]) ?>
 
 <?= $this->endSection() ?>
diff --git a/app/Views/admin/page/create.php b/modules/Admin/Views/page/create.php
similarity index 97%
rename from app/Views/admin/page/create.php
rename to modules/Admin/Views/page/create.php
index 711f7fdf9ad04fe03586e9c99176ec8bfd0f4ccb..58769124fd8d5d4d9e86fb438aebcbf5a420110e 100644
--- a/app/Views/admin/page/create.php
+++ b/modules/Admin/Views/page/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Page.create') ?>
diff --git a/app/Views/admin/page/edit.php b/modules/Admin/Views/page/edit.php
similarity index 97%
rename from app/Views/admin/page/edit.php
rename to modules/Admin/Views/page/edit.php
index 0b2902d8d8a52a29087bf4d373491c287eb740c8..5f2353fde3473cea52282f8dcbb1714b877e5696 100644
--- a/app/Views/admin/page/edit.php
+++ b/modules/Admin/Views/page/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Page.edit') ?>
diff --git a/app/Views/admin/page/list.php b/modules/Admin/Views/page/list.php
similarity index 97%
rename from app/Views/admin/page/list.php
rename to modules/Admin/Views/page/list.php
index 2a113a21f1cfdfac2ba27c166c458a3df4c53363..c3faf20cc0d9bff6f5882bba4dd850df6903410c 100644
--- a/app/Views/admin/page/list.php
+++ b/modules/Admin/Views/page/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Page.all_pages') ?>
diff --git a/app/Views/admin/page/view.php b/modules/Admin/Views/page/view.php
similarity index 90%
rename from app/Views/admin/page/view.php
rename to modules/Admin/Views/page/view.php
index 029b9587a428129285bb34ebba9d3f04af32e793..4d7a59d959375c40955225ca4b2cab3b8c530ddd 100644
--- a/app/Views/admin/page/view.php
+++ b/modules/Admin/Views/page/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $page->title ?>
diff --git a/app/Views/admin/person/create.php b/modules/Admin/Views/person/create.php
similarity index 97%
rename from app/Views/admin/person/create.php
rename to modules/Admin/Views/person/create.php
index 510d8f939d8a34a7f3efa75b4992c9e9c16cc35d..3458cf739b253b4a4396be8063b76faac9701a17 100644
--- a/app/Views/admin/person/create.php
+++ b/modules/Admin/Views/person/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Person.create') ?>
diff --git a/app/Views/admin/person/edit.php b/modules/Admin/Views/person/edit.php
similarity index 97%
rename from app/Views/admin/person/edit.php
rename to modules/Admin/Views/person/edit.php
index b46996185f7dac9abb357b8981970aeaa36d9156..bbca43db0c50aee3aec2ee0fd95c257d8d948670 100644
--- a/app/Views/admin/person/edit.php
+++ b/modules/Admin/Views/person/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Person.edit') ?>
diff --git a/app/Views/admin/person/list.php b/modules/Admin/Views/person/list.php
similarity index 97%
rename from app/Views/admin/person/list.php
rename to modules/Admin/Views/person/list.php
index d093e48298395aa68fc8caaff8a00a1aa8594d85..ef2a72995557ce08b602c51798350e096abd16dd 100644
--- a/app/Views/admin/person/list.php
+++ b/modules/Admin/Views/person/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Person.all_persons') ?>
diff --git a/app/Views/admin/person/view.php b/modules/Admin/Views/person/view.php
similarity index 94%
rename from app/Views/admin/person/view.php
rename to modules/Admin/Views/person/view.php
index 2191606417a1501b2253475ea068edaed8b9d94a..19a277ded88510ef323971d6be74e716ba63a7b1 100644
--- a/app/Views/admin/person/view.php
+++ b/modules/Admin/Views/person/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $person->full_name ?>
diff --git a/app/Views/admin/podcast/_sidebar.php b/modules/Admin/Views/podcast/_sidebar.php
similarity index 100%
rename from app/Views/admin/podcast/_sidebar.php
rename to modules/Admin/Views/podcast/_sidebar.php
diff --git a/app/Views/admin/podcast/analytics/index.php b/modules/Admin/Views/podcast/analytics/index.php
similarity index 95%
rename from app/Views/admin/podcast/analytics/index.php
rename to modules/Admin/Views/podcast/analytics/index.php
index 0d691f9ad5a59ff3ba3cdac6597a5b1b47ce9a02..c80e79670ac9feb40530ceb1695eb23ff6a398a1 100644
--- a/app/Views/admin/podcast/analytics/index.php
+++ b/modules/Admin/Views/podcast/analytics/index.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/listening_time.php b/modules/Admin/Views/podcast/analytics/listening_time.php
similarity index 94%
rename from app/Views/admin/podcast/analytics/listening_time.php
rename to modules/Admin/Views/podcast/analytics/listening_time.php
index 98a7fd4bf03f41cc24bd5ddc7bb8e31c07cfc9af..23a7f9896b17e3f0edc8a7f8753c9f8258d3715e 100644
--- a/app/Views/admin/podcast/analytics/listening_time.php
+++ b/modules/Admin/Views/podcast/analytics/listening_time.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/locations.php b/modules/Admin/Views/podcast/analytics/locations.php
similarity index 96%
rename from app/Views/admin/podcast/analytics/locations.php
rename to modules/Admin/Views/podcast/analytics/locations.php
index d2164889b6fa5ed859b26ba2c46de3f9682c062c..cb4c5e9709e0f9b814abfac568f8385a1300494e 100644
--- a/app/Views/admin/podcast/analytics/locations.php
+++ b/modules/Admin/Views/podcast/analytics/locations.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/players.php b/modules/Admin/Views/podcast/analytics/players.php
similarity index 97%
rename from app/Views/admin/podcast/analytics/players.php
rename to modules/Admin/Views/podcast/analytics/players.php
index 08c03b43da959b28531f8de4ac1007fc4edb9217..6688d499cc771713ccdf1de4553af54618789c09 100644
--- a/app/Views/admin/podcast/analytics/players.php
+++ b/modules/Admin/Views/podcast/analytics/players.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/time_periods.php b/modules/Admin/Views/podcast/analytics/time_periods.php
similarity index 95%
rename from app/Views/admin/podcast/analytics/time_periods.php
rename to modules/Admin/Views/podcast/analytics/time_periods.php
index 1c8329f1bb71c2d446f7c6ed6f8cf01cf86ada7c..aad964d0c8d9d1a9ff251c081096df7002d60023 100644
--- a/app/Views/admin/podcast/analytics/time_periods.php
+++ b/modules/Admin/Views/podcast/analytics/time_periods.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/unique_listeners.php b/modules/Admin/Views/podcast/analytics/unique_listeners.php
similarity index 94%
rename from app/Views/admin/podcast/analytics/unique_listeners.php
rename to modules/Admin/Views/podcast/analytics/unique_listeners.php
index 279d7652140a299fde66110d30a950e77287d308..9a2c01db96c95f698b673e3158c10a1b159d64fd 100644
--- a/app/Views/admin/podcast/analytics/unique_listeners.php
+++ b/modules/Admin/Views/podcast/analytics/unique_listeners.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/analytics/webpages.php b/modules/Admin/Views/podcast/analytics/webpages.php
similarity index 97%
rename from app/Views/admin/podcast/analytics/webpages.php
rename to modules/Admin/Views/podcast/analytics/webpages.php
index 05689f76d98ade679ae96160bd36faa7d237d56b..befddd72175ae26101d2695fb118c8efb3223c37 100644
--- a/app/Views/admin/podcast/analytics/webpages.php
+++ b/modules/Admin/Views/podcast/analytics/webpages.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
diff --git a/app/Views/admin/podcast/create.php b/modules/Admin/Views/podcast/create.php
similarity index 99%
rename from app/Views/admin/podcast/create.php
rename to modules/Admin/Views/podcast/create.php
index 5bdbbf8ad9693c1135020e5f8822a1084ee9582d..c104803b65c36f7de2f140a5d33052209cbd9d05 100644
--- a/app/Views/admin/podcast/create.php
+++ b/modules/Admin/Views/podcast/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Podcast.create') ?>
diff --git a/app/Views/admin/podcast/edit.php b/modules/Admin/Views/podcast/edit.php
similarity index 99%
rename from app/Views/admin/podcast/edit.php
rename to modules/Admin/Views/podcast/edit.php
index 8523209c955c020c10525808fbe311072b0971e4..13e8ece09da663dc4d7a1e84fa7705f97edb2e0b 100644
--- a/app/Views/admin/podcast/edit.php
+++ b/modules/Admin/Views/podcast/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Podcast.edit') ?>
diff --git a/app/Views/admin/podcast/import.php b/modules/Admin/Views/podcast/import.php
similarity index 99%
rename from app/Views/admin/podcast/import.php
rename to modules/Admin/Views/podcast/import.php
index 369a8a4b62355e4570f4998b1b431aaac920fc68..f8740b7a3d5e79fbd36b6edab7b1c380e930a744 100644
--- a/app/Views/admin/podcast/import.php
+++ b/modules/Admin/Views/podcast/import.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Podcast.import') ?>
diff --git a/app/Views/admin/podcast/latest_episodes.php b/modules/Admin/Views/podcast/latest_episodes.php
similarity index 100%
rename from app/Views/admin/podcast/latest_episodes.php
rename to modules/Admin/Views/podcast/latest_episodes.php
diff --git a/app/Views/admin/podcast/list.php b/modules/Admin/Views/podcast/list.php
similarity index 97%
rename from app/Views/admin/podcast/list.php
rename to modules/Admin/Views/podcast/list.php
index 28f802177f2ea6693a88f4143b9b1e472adc1a64..f9ddce561d33150d14e8a71d93fd923889b30ec6 100644
--- a/app/Views/admin/podcast/list.php
+++ b/modules/Admin/Views/podcast/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Podcast.all_podcasts') ?>
diff --git a/app/Views/admin/podcast/persons.php b/modules/Admin/Views/podcast/persons.php
similarity index 98%
rename from app/Views/admin/podcast/persons.php
rename to modules/Admin/Views/podcast/persons.php
index f9360ec0f64b5b9fc0b1caffd29d2e2f571e8759..2e51cacb156bf550f9d3c6252525b70572806cfa 100644
--- a/app/Views/admin/podcast/persons.php
+++ b/modules/Admin/Views/podcast/persons.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Person.podcast_form.title') ?>
diff --git a/app/Views/admin/podcast/platforms.php b/modules/Admin/Views/podcast/platforms.php
similarity index 99%
rename from app/Views/admin/podcast/platforms.php
rename to modules/Admin/Views/podcast/platforms.php
index 77b46d34933f86a5a6f0b7d6c13db9ff0e47ab54..95d72e03a13f998219fdce8d2758953683db4de2 100644
--- a/app/Views/admin/podcast/platforms.php
+++ b/modules/Admin/Views/podcast/platforms.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Platforms.title') ?>
diff --git a/app/Views/admin/podcast/settings/dashboard.php b/modules/Admin/Views/podcast/settings/dashboard.php
similarity index 84%
rename from app/Views/admin/podcast/settings/dashboard.php
rename to modules/Admin/Views/podcast/settings/dashboard.php
index 4b9bfa561ddb702635485147d5c80945542802c5..ac35812f4ac743a2fea18eddc9c6e635f5d368ef 100644
--- a/app/Views/admin/podcast/settings/dashboard.php
+++ b/modules/Admin/Views/podcast/settings/dashboard.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('Podcast.platforms.title') ?>
diff --git a/app/Views/admin/podcast/view.php b/modules/Admin/Views/podcast/view.php
similarity index 85%
rename from app/Views/admin/podcast/view.php
rename to modules/Admin/Views/podcast/view.php
index 8bf1c6f058acdd21d149731ea52ff5d3d8714ded..dc3634c785eea92e8847f33445485b4e8a892799 100644
--- a/app/Views/admin/podcast/view.php
+++ b/modules/Admin/Views/podcast/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= $podcast->title ?>
@@ -27,7 +27,7 @@
 
 <?= $this->section('content') ?>
 
-<?= view_cell('\App\Controllers\Admin\PodcastController::latestEpisodes', [
+<?= view_cell('Modules\Admin\Controllers\PodcastController::latestEpisodes', [
     'limit' => 5,
     'podcast_id' => $podcast->id,
 ]) ?>
diff --git a/app/Views/admin/user/create.php b/modules/Admin/Views/user/create.php
similarity index 95%
rename from app/Views/admin/user/create.php
rename to modules/Admin/Views/user/create.php
index afc93cb876ecc3abb23fa5db346898be12ffb615..7175b4330ee350f6fa198b1122e08c8597de1f8d 100644
--- a/app/Views/admin/user/create.php
+++ b/modules/Admin/Views/user/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('User.create') ?>
diff --git a/app/Views/admin/user/edit.php b/modules/Admin/Views/user/edit.php
similarity index 93%
rename from app/Views/admin/user/edit.php
rename to modules/Admin/Views/user/edit.php
index d57f80842b26a06f485969d5eae82efa20aa8937..be89f21c51a3089a26d0315a81bd1509b84bb672 100644
--- a/app/Views/admin/user/edit.php
+++ b/modules/Admin/Views/user/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('User.edit_roles', ['username' => $user->username]) ?>
diff --git a/app/Views/admin/user/list.php b/modules/Admin/Views/user/list.php
similarity index 98%
rename from app/Views/admin/user/list.php
rename to modules/Admin/Views/user/list.php
index a7e34bea017f353a554ea75c5129c47f8e27ba44..2771199f77e795c4210e2edc5cadb253b8269c57 100644
--- a/app/Views/admin/user/list.php
+++ b/modules/Admin/Views/user/list.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('User.all_users') ?>
diff --git a/app/Views/admin/user/view.php b/modules/Admin/Views/user/view.php
similarity index 58%
rename from app/Views/admin/user/view.php
rename to modules/Admin/Views/user/view.php
index 42aa6495f80ad76e5493c7f64fec78628120a4ca..462519ffd779ff0f923f16e830636a952b566c62 100644
--- a/app/Views/admin/user/view.php
+++ b/modules/Admin/Views/user/view.php
@@ -1,4 +1,4 @@
-<?= $this->extend('admin/_layout') ?>
+<?= $this->extend('Modules\Admin\Views\_layout') ?>
 
 <?= $this->section('title') ?>
 <?= lang('User.view', ['username' => $user->username]) ?>
@@ -7,6 +7,6 @@
 
 <?= $this->section('content') ?>
 
-<?= view('admin/_partials/_user_info.php', ['user' => $user]) ?>
+<?= view('Modules\Admin\Views\_partials/_user_info.php', ['user' => $user]) ?>
 
 <?= $this->endSection() ?>
diff --git a/app/Libraries/Analytics/AnalyticsTrait.php b/modules/Analytics/AnalyticsTrait.php
similarity index 97%
rename from app/Libraries/Analytics/AnalyticsTrait.php
rename to modules/Analytics/AnalyticsTrait.php
index 87c9d0d5234cf7842eee114615c82bbf809d7005..44b5718c4e0179c306aa6465a9d960fddc49fd60 100644
--- a/app/Libraries/Analytics/AnalyticsTrait.php
+++ b/modules/Analytics/AnalyticsTrait.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics;
+namespace Modules\Analytics;
 
 use Config\Services;
 
diff --git a/app/Config/Analytics.php b/modules/Analytics/Config/Analytics.php
similarity index 65%
rename from app/Config/Analytics.php
rename to modules/Analytics/Config/Analytics.php
index 3853f4e77d896a874937113dc650c99caf6ae852..7e3957d66d8331500fe7e5f6aa8be66a4fe875ff 100644
--- a/app/Config/Analytics.php
+++ b/modules/Analytics/Config/Analytics.php
@@ -2,16 +2,22 @@
 
 declare(strict_types=1);
 
-namespace Config;
+namespace Modules\Analytics\Config;
 
-use Analytics\Config\Analytics as AnalyticsBase;
+use CodeIgniter\Config\BaseConfig;
 
-class Analytics extends AnalyticsBase
+class Analytics extends BaseConfig
 {
+    /**
+     * Gateway to analytic routes. By default, all analytics routes will be under `/analytics` path
+     */
+    public string $gateway = 'analytics';
+
     /**
      * --------------------------------------------------------------------
      * Route filters options
      * --------------------------------------------------------------------
+     * @var array<string, string>
      */
     public array $routeFilters = [
         'analytics-full-data' => 'permission:podcasts-view,podcast-view',
@@ -19,16 +25,6 @@ class Analytics extends AnalyticsBase
         'analytics-filtered-data' => 'permission:podcasts-view,podcast-view',
     ];
 
-    public function __construct()
-    {
-        parent::__construct();
-
-        // set the analytics gateway behind the admin gateway.
-        // Only logged in users should be able to view analytics
-        $this->gateway = config('App')
-            ->adminGateway . '/analytics';
-    }
-
     /**
      * get the full audio file url
      *
diff --git a/app/Libraries/Analytics/Config/Routes.php b/modules/Analytics/Config/Routes.php
similarity index 96%
rename from app/Libraries/Analytics/Config/Routes.php
rename to modules/Analytics/Config/Routes.php
index 7fe14863ed2e50d9c60eccbc6690a62893eaf348..cd43dca19e5e49788eaca8b8d00f57dda7e4e553 100644
--- a/app/Libraries/Analytics/Config/Routes.php
+++ b/modules/Analytics/Config/Routes.php
@@ -8,10 +8,11 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
+$routes = service('routes');
+
 /**
  * Analytics routes file
  */
-
 $routes->addPlaceholder(
     'class',
     '\bPodcastByCountry|\bPodcastByEpisode|\bPodcastByHour|\bPodcastByPlayer|\bPodcastByRegion|\bPodcastByService|\bPodcast|\bWebsiteByBrowser|\bWebsiteByEntryPage|\bWebsiteByReferer',
@@ -22,7 +23,7 @@ $routes->addPlaceholder(
 );
 
 $routes->group('', [
-    'namespace' => 'Analytics\Controllers',
+    'namespace' => 'Modules\Analytics\Controllers',
 ], function ($routes): void {
     $routes->group(config('Analytics')->gateway . '/(:num)/(:class)', function ($routes): void {
         $routes->get('/', 'AnalyticsController::getData/$1/$2', [
diff --git a/app/Libraries/Analytics/Controllers/AnalyticsController.php b/modules/Analytics/Controllers/AnalyticsController.php
similarity index 96%
rename from app/Libraries/Analytics/Controllers/AnalyticsController.php
rename to modules/Analytics/Controllers/AnalyticsController.php
index e0ed21af6212588bd1455c40bdf65a747e929c3f..e9ac5078d1f385b93c1c0adeca7257db34a850ec 100644
--- a/app/Libraries/Analytics/Controllers/AnalyticsController.php
+++ b/modules/Analytics/Controllers/AnalyticsController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Controllers;
+namespace Modules\Analytics\Controllers;
 
 use CodeIgniter\Controller;
 use CodeIgniter\Exceptions\PageNotFoundException;
diff --git a/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php b/modules/Analytics/Controllers/EpisodeAnalyticsController.php
similarity index 96%
rename from app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php
rename to modules/Analytics/Controllers/EpisodeAnalyticsController.php
index e3d42999bf69bc7d00baed345c87b7df3764c091..9b7a996977e16fc4f528069d8956a1ca6c8a6f90 100644
--- a/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php
+++ b/modules/Analytics/Controllers/EpisodeAnalyticsController.php
@@ -8,14 +8,14 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Controllers;
+namespace Modules\Analytics\Controllers;
 
-use Analytics\Config\Analytics;
 use CodeIgniter\Controller;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\RequestInterface;
 use CodeIgniter\HTTP\ResponseInterface;
 use Config\Services;
+use Modules\Analytics\Config\Analytics;
 use Psr\Log\LoggerInterface;
 
 class EpisodeAnalyticsController extends Controller
diff --git a/app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php b/modules/Analytics/Controllers/UnknownUserAgentsController.php
similarity index 92%
rename from app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php
rename to modules/Analytics/Controllers/UnknownUserAgentsController.php
index 0747f6edfc9ca8f1fec78a34da83ecb9dcd4d23c..ca50bcb9185646f3e8a66a2fcff89d96ca318f99 100644
--- a/app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php
+++ b/modules/Analytics/Controllers/UnknownUserAgentsController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Controllers;
+namespace Modules\Analytics\Controllers;
 
 use CodeIgniter\Controller;
 use CodeIgniter\HTTP\ResponseInterface;
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php b/modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
similarity index 97%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
rename to modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
index 1db9da1a3849015d27f5a14054224e17caac09ae..8ac6f944f6fe5f672f2e871b655eb1ec37bbe3c3 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php b/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
rename to modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
index 27e0412618b9482f77b3be787a237746dc3f1545..9343c6c42fb0fb39c3c249143ec6bfa41cf15613 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php b/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
rename to modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
index ad4ee5c5ffdf4f3ba546010e0a7a43f06a777446..7a8ea647b1ab05c67c89a0874dc3fd4c308da403 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php b/modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
similarity index 97%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
rename to modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
index 09e4386d45b4ed0a051bfb82b1f39500ba07a03a..a29357a50994625abe5d5d360968ad50f6772a49 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php b/modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
rename to modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
index 069302794d73dbe3065e1f89319bab120cd92acb..c202d7f52bdd16c8434749642aae516d61787c26 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php b/modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
similarity index 97%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
rename to modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
index 39d3149243b602aa74e8bb263dd574183de9849f..3c68b50a818c7c00c7ebe3e8ecb1c368dc6b392a 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php b/modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
rename to modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
index bea21b069458c5b6494c4c645c0f02c3c45cee34..e6901ad5fe6ea1c66e2a57bc5c897071ca62e603 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php b/modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
similarity index 97%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
rename to modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
index c43005da1d09117cf6b17d6bf5ff5b4f35589a69..fb75e882a84dc60f8d3ad2be2d57dfc0735474f4 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php b/modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
rename to modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
index d83cbba3c411c1ea1856b091d2ca04556afdf2fb..6817ee7b70d393f4d63d478d5f664aa062e536b8 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php b/modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
rename to modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
index 801cd9638e06b5aa38b751212dd697b5e5571fe9..927b57830473116473518d4a3f783bac2c5c6e13 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
similarity index 98%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
rename to modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
index c6baebef829e3e21e3d9b2eb35c164fde71fda50..18a9276e43eb708e5ace5e134a3076a128a065e1 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php
similarity index 96%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php
rename to modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php
index e7a0f9b010edea48b9ca55a41209638e91f1b85a..21d6299adc8a094474cad199914b7fca7a56d8b5 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php
similarity index 97%
rename from app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php
rename to modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php
index 8222abd9c7598d009c6fd8339b4cb712fe8b6fab..4eda40cc1dd02d25f4b9f6fca20c3e369224ac88 100644
--- a/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Database\Migrations;
+namespace Modules\Analytics\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcasts.php b/modules/Analytics/Entities/AnalyticsPodcasts.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcasts.php
rename to modules/Analytics/Entities/AnalyticsPodcasts.php
index 12c9fc66e897ccd0b5bb0d08d4fe0229b9882f4a..4d9d31a3fb2487f15bec58f9313f6bbc9ff7b649 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcasts.php
+++ b/modules/Analytics/Entities/AnalyticsPodcasts.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php b/modules/Analytics/Entities/AnalyticsPodcastsByCountry.php
similarity index 96%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByCountry.php
index ca9ed80ecf73f3da6658c263a689f3a0194d17c6..9af24d83dc679e835cfea41f074894895c83cfad 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByCountry.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php b/modules/Analytics/Entities/AnalyticsPodcastsByEpisode.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByEpisode.php
index 07dd6967268323be074b6767ce1633d2289d1ce1..e1e627fe28b38f4374aadbe2ca81804f58517d41 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByEpisode.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php b/modules/Analytics/Entities/AnalyticsPodcastsByHour.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByHour.php
index 88856020654e1cf23bc9bde950edab49f33cbfa1..cac57719b46a813e5c16706851d56f9ae68bbd7b 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByHour.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php b/modules/Analytics/Entities/AnalyticsPodcastsByPlayer.php
similarity index 96%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByPlayer.php
index b35435877b6a6da3edd0b1c22fadcd7339783f5d..cd11bd3e19dda1592b02e575dc9876b7b5b87391 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByPlayer.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php b/modules/Analytics/Entities/AnalyticsPodcastsByRegion.php
similarity index 96%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByRegion.php
index f193f3cfd85017b6885a8054849e762c8846bcb7..9af222183f79654b628629f7fd390922cc0e684f 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByRegion.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php b/modules/Analytics/Entities/AnalyticsPodcastsByService.php
similarity index 96%
rename from app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php
rename to modules/Analytics/Entities/AnalyticsPodcastsByService.php
index 6604820f67ae460f96a8916da9a89a4090cab3bc..034e8bc33f0562673c183bbe881ed70ce8198fb3 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php
+++ b/modules/Analytics/Entities/AnalyticsPodcastsByService.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 use Opawg\UserAgentsPhp\UserAgentsRSS;
diff --git a/app/Libraries/Analytics/Entities/AnalyticsUnknownUserAgent.php b/modules/Analytics/Entities/AnalyticsUnknownUserAgent.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsUnknownUserAgent.php
rename to modules/Analytics/Entities/AnalyticsUnknownUserAgent.php
index b5d460e8192e345013e32ca20a3fba5ae800385c..95675809991e7aa82aa03e4a4c722bb651d07987 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsUnknownUserAgent.php
+++ b/modules/Analytics/Entities/AnalyticsUnknownUserAgent.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php b/modules/Analytics/Entities/AnalyticsWebsiteByBrowser.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php
rename to modules/Analytics/Entities/AnalyticsWebsiteByBrowser.php
index 0dcb8e341388af4e4c02ec1149c0305222c55f36..7e997bd3b93de1570f348b067e216055e9a4475f 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php
+++ b/modules/Analytics/Entities/AnalyticsWebsiteByBrowser.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php b/modules/Analytics/Entities/AnalyticsWebsiteByEntryPage.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php
rename to modules/Analytics/Entities/AnalyticsWebsiteByEntryPage.php
index 4e59db25d942eb4c271b69759379bbf6abeb0647..8257d86e3e0e28f10c4e9f95dbea2f6aa8bcee3b 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php
+++ b/modules/Analytics/Entities/AnalyticsWebsiteByEntryPage.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php b/modules/Analytics/Entities/AnalyticsWebsiteByReferer.php
similarity index 95%
rename from app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php
rename to modules/Analytics/Entities/AnalyticsWebsiteByReferer.php
index 570fad0de9980c1a89516966545eb5d9be7f5a21..3a4f77179d58c834013b3e73f447ee20156b5cd6 100644
--- a/app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php
+++ b/modules/Analytics/Entities/AnalyticsWebsiteByReferer.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Entities;
+namespace Modules\Analytics\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/Analytics/Helpers/analytics_helper.php b/modules/Analytics/Helpers/analytics_helper.php
similarity index 100%
rename from app/Libraries/Analytics/Helpers/analytics_helper.php
rename to modules/Analytics/Helpers/analytics_helper.php
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php b/modules/Analytics/Models/AnalyticsPodcastByCountryModel.php
similarity index 96%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByCountryModel.php
index c15b671fe15761880443795a1c837c495361a2b2..52844b4b15ced5f92b5a81cd0958d20aeb19a723 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByCountryModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByCountry;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByCountry;
 
 class AnalyticsPodcastByCountryModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php b/modules/Analytics/Models/AnalyticsPodcastByEpisodeModel.php
similarity index 96%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByEpisodeModel.php
index a1a633885cf27c9cd0ea48da009d927ea063e842..8d20ab4b8a1d8ea77b2b85dd9f3860e3e7de493a 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByEpisodeModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByEpisode;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByEpisode;
 
 class AnalyticsPodcastByEpisodeModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php b/modules/Analytics/Models/AnalyticsPodcastByHourModel.php
similarity index 94%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByHourModel.php
index d582f77a30669ea575e9c058ee8700ebb6f486db..e70225f15bd969fa33ce98292a88466614229855 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByHourModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByHour;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByHour;
 
 class AnalyticsPodcastByHourModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php b/modules/Analytics/Models/AnalyticsPodcastByPlayerModel.php
similarity index 98%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByPlayerModel.php
index 76da85df056967e2088bdd0363a08c3f68e38589..8050401190a379d8e64315d94317bb153a492d0b 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByPlayerModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByPlayer;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByPlayer;
 
 class AnalyticsPodcastByPlayerModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php b/modules/Analytics/Models/AnalyticsPodcastByRegionModel.php
similarity index 94%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByRegionModel.php
index 22d59d6fcd861e534e465b0cb3ba82daf4b9b533..b21f1a5a10a61e711827757fe6fe75c5226ee33c 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByRegionModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByRegion;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByRegion;
 
 class AnalyticsPodcastByRegionModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php b/modules/Analytics/Models/AnalyticsPodcastByServiceModel.php
similarity index 94%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php
rename to modules/Analytics/Models/AnalyticsPodcastByServiceModel.php
index 160c8327875067eec711d5888df710cea889c298..b71ee0ca1370e3fc6bfe773be4bcfb9df19e329b 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastByServiceModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcastsByService;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsByService;
 
 class AnalyticsPodcastByServiceModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastModel.php b/modules/Analytics/Models/AnalyticsPodcastModel.php
similarity index 98%
rename from app/Libraries/Analytics/Models/AnalyticsPodcastModel.php
rename to modules/Analytics/Models/AnalyticsPodcastModel.php
index f2f2a7025ddef9775d6ca63efa4592240c2aead6..893d3cf8b293642336e7eb9408692d2bb570f5cc 100644
--- a/app/Libraries/Analytics/Models/AnalyticsPodcastModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsPodcasts;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcasts;
 
 class AnalyticsPodcastModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php b/modules/Analytics/Models/AnalyticsUnknownUseragentsModel.php
similarity index 90%
rename from app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php
rename to modules/Analytics/Models/AnalyticsUnknownUseragentsModel.php
index f3230eaad99fd5459739a971100d46b47aaa88ab..ad96ee03360f8accbf866c1054228b4705047b81 100644
--- a/app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php
+++ b/modules/Analytics/Models/AnalyticsUnknownUseragentsModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsUnknownUserAgent;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsUnknownUserAgent;
 
 class AnalyticsUnknownUserAgentModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php b/modules/Analytics/Models/AnalyticsWebsiteByBrowserModel.php
similarity index 94%
rename from app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php
rename to modules/Analytics/Models/AnalyticsWebsiteByBrowserModel.php
index 438beda401442f772ed08c51929ca302f7588915..326aa7f015223e43930785255c0484905f82232f 100644
--- a/app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php
+++ b/modules/Analytics/Models/AnalyticsWebsiteByBrowserModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsWebsiteByBrowser;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsWebsiteByBrowser;
 
 class AnalyticsWebsiteByBrowserModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php b/modules/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php
similarity index 94%
rename from app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php
rename to modules/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php
index b49bb1a6ca619b167201ad6c3ce81d801ed1cab2..367a965c0fe7eb3288b2690127edb91abb66b176 100644
--- a/app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php
+++ b/modules/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsWebsiteByEntryPage;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsWebsiteByEntryPage;
 
 class AnalyticsWebsiteByEntryPageModel extends Model
 {
diff --git a/app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php b/modules/Analytics/Models/AnalyticsWebsiteByRefererModel.php
similarity index 97%
rename from app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php
rename to modules/Analytics/Models/AnalyticsWebsiteByRefererModel.php
index fd4ff8185dd130710a4eceb4974cd9d32346a06f..8e64d78da306fa3e1bc646c4b624fe0ce78c2633 100644
--- a/app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php
+++ b/modules/Analytics/Models/AnalyticsWebsiteByRefererModel.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Analytics\Models;
+namespace Modules\Analytics\Models;
 
-use Analytics\Entities\AnalyticsWebsiteByReferer;
 use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsWebsiteByReferer;
 
 class AnalyticsWebsiteByRefererModel extends Model
 {
diff --git a/app/Authorization/FlatAuthorization.php b/modules/Auth/Authorization/FlatAuthorization.php
similarity index 97%
rename from app/Authorization/FlatAuthorization.php
rename to modules/Auth/Authorization/FlatAuthorization.php
index 31d9063024861d33e1398c306f51d64855303749..933a27287736186b786e5eefba00a0bcf108c47a 100644
--- a/app/Authorization/FlatAuthorization.php
+++ b/modules/Auth/Authorization/FlatAuthorization.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Authorization;
+namespace Modules\Auth\Authorization;
 
 use Myth\Auth\Authorization\FlatAuthorization as MythAuthFlatAuthorization;
 
diff --git a/app/Authorization/GroupModel.php b/modules/Auth/Authorization/GroupModel.php
similarity index 93%
rename from app/Authorization/GroupModel.php
rename to modules/Auth/Authorization/GroupModel.php
index 1e2e74a355353f3fab33bb44a056ba2acf1126fd..746185420384fda9286e6edf12c1f01c6c3a2850 100644
--- a/app/Authorization/GroupModel.php
+++ b/modules/Auth/Authorization/GroupModel.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Authorization;
+namespace Modules\Auth\Authorization;
 
 use Myth\Auth\Authorization\GroupModel as MythAuthGroupModel;
 
diff --git a/app/Authorization/PermissionModel.php b/modules/Auth/Authorization/PermissionModel.php
similarity index 97%
rename from app/Authorization/PermissionModel.php
rename to modules/Auth/Authorization/PermissionModel.php
index 72329220d521524c83778b96cff4c81de3379fd2..01106c100481235f6887a987be80477cbadc256b 100644
--- a/app/Authorization/PermissionModel.php
+++ b/modules/Auth/Authorization/PermissionModel.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Authorization;
+namespace Modules\Auth\Authorization;
 
 use Myth\Auth\Authorization\PermissionModel as MythAuthPermissionModel;
 
diff --git a/app/Config/Auth.php b/modules/Auth/Config/Auth.php
similarity index 68%
rename from app/Config/Auth.php
rename to modules/Auth/Config/Auth.php
index 08e2e6249a63703908c7a3ef2cb6ddee9273fd28..98be59e61cb4a3e7e9c6d6b245fdaf504e61bb2a 100644
--- a/app/Config/Auth.php
+++ b/modules/Auth/Config/Auth.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Config;
+namespace Modules\Auth\Config;
 
 use Myth\Auth\Config\Auth as MythAuthConfig;
 
@@ -16,12 +16,12 @@ class Auth extends MythAuthConfig
      * @var array<string, string>
      */
     public $views = [
-        'login' => 'auth/login',
-        'register' => 'auth/register',
-        'forgot' => 'auth/forgot',
-        'reset' => 'auth/reset',
-        'emailForgot' => 'auth/emails/forgot',
-        'emailActivation' => 'auth/emails/activation',
+        'login' => 'Modules\Auth\Views\login',
+        'register' => 'Modules\Auth\Views\register',
+        'forgot' => 'Modules\Auth\Views\forgot',
+        'reset' => 'Modules\Auth\Views\reset',
+        'emailForgot' => 'Modules\Auth\Views\emails\forgot',
+        'emailActivation' => 'Modules\Auth\Views\emails\activation',
     ];
 
     /**
@@ -31,7 +31,7 @@ class Auth extends MythAuthConfig
      *
      * @var string
      */
-    public $viewLayout = 'auth/_layout';
+    public $viewLayout = 'Modules\Auth\Views\_layout';
 
     /**
      * --------------------------------------------------------------------------
@@ -55,4 +55,12 @@ class Auth extends MythAuthConfig
      * @var bool
      */
     public $requireActivation = false;
+
+    /**
+     * --------------------------------------------------------------------------
+     * Auth gateway
+     * --------------------------------------------------------------------------
+     * Defines a base route for all authentication related pages
+     */
+    public string $gateway = 'cp-auth';
 }
diff --git a/modules/Auth/Config/Routes.php b/modules/Auth/Config/Routes.php
new file mode 100644
index 0000000000000000000000000000000000000000..044cdda22982665837ba3a514de4a19172bc84f4
--- /dev/null
+++ b/modules/Auth/Config/Routes.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Config;
+
+$routes = service('routes');
+
+/**
+ * Overwriting Myth:auth routes file
+ */
+$routes->group(
+    config('Auth')
+        ->gateway,
+    [
+        'namespace' => 'Modules\Auth\Controllers',
+    ],
+    function ($routes): void {
+        // Login/out
+        $routes->get('login', 'AuthController::login', [
+            'as' => 'login',
+        ]);
+        $routes->post('login', 'AuthController::attemptLogin');
+        $routes->get('logout', 'AuthController::logout', [
+            'as' => 'logout',
+        ]);
+
+        // Registration
+        $routes->get('register', 'AuthController::register', [
+            'as' => 'register',
+        ]);
+        $routes->post('register', 'AuthController::attemptRegister');
+
+        // Activation
+        $routes->get('activate-account', 'AuthController::activateAccount', [
+            'as' => 'activate-account',
+        ]);
+        $routes->get(
+            'resend-activate-account',
+            'AuthController::resendActivateAccount',
+            [
+                'as' => 'resend-activate-account',
+            ],
+        );
+
+        // Forgot/Resets
+        $routes->get('forgot', 'AuthController::forgotPassword', [
+            'as' => 'forgot',
+        ]);
+        $routes->post('forgot', 'AuthController::attemptForgot');
+        $routes->get('reset-password', 'AuthController::resetPassword', [
+            'as' => 'reset-password',
+        ]);
+        $routes->post('reset-password', 'AuthController::attemptReset');
+
+        // interacting as an actor
+        $routes->post('interact-as-actor', 'AuthController::attemptInteractAsActor', [
+            'as' => 'interact-as-actor',
+        ]);
+    }
+);
diff --git a/modules/Auth/Config/Services.php b/modules/Auth/Config/Services.php
new file mode 100644
index 0000000000000000000000000000000000000000..71b8129c8cd851b791fea7409cb94528f7ce7fda
--- /dev/null
+++ b/modules/Auth/Config/Services.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Config;
+
+use App\Models\UserModel;
+use CodeIgniter\Config\BaseService;
+use CodeIgniter\Model;
+use Modules\Auth\Authorization\FlatAuthorization;
+use Modules\Auth\Authorization\GroupModel;
+use Modules\Auth\Authorization\PermissionModel;
+use Myth\Auth\Models\LoginModel;
+
+/**
+ * Services Configuration file.
+ *
+ * Services are simply other classes/libraries that the system uses to do its job. This is used by CodeIgniter to allow
+ * the core of the framework to be swapped out easily without affecting the usage within the rest of your application.
+ *
+ * This file holds any application-specific services, or service overrides that you might need. An example has been
+ * included with the general method format you should use for your service methods. For more examples, see the core
+ * Services file at system/Config/Services.php.
+ */
+class Services extends BaseService
+{
+    /**
+     * @return mixed
+     */
+    public static function authentication(
+        string $lib = 'local',
+        Model $userModel = null,
+        Model $loginModel = null,
+        bool $getShared = true
+    ) {
+        if ($getShared) {
+            return self::getSharedInstance('authentication', $lib, $userModel, $loginModel);
+        }
+
+        // config() checks first in app/Config
+        $config = config('Auth');
+
+        $class = $config->authenticationLibs[$lib];
+
+        $instance = new $class($config);
+
+        if ($userModel === null) {
+            $userModel = new UserModel();
+        }
+
+        if ($loginModel === null) {
+            $loginModel = new LoginModel();
+        }
+
+        return $instance->setUserModel($userModel)
+            ->setLoginModel($loginModel);
+    }
+
+    /**
+     * @return mixed|$this
+     */
+    public static function authorization(
+        Model $groupModel = null,
+        Model $permissionModel = null,
+        Model $userModel = null,
+        bool $getShared = true
+    ) {
+        if ($getShared) {
+            return self::getSharedInstance('authorization', $groupModel, $permissionModel, $userModel);
+        }
+
+        if ($groupModel === null) {
+            $groupModel = new GroupModel();
+        }
+
+        if ($permissionModel === null) {
+            $permissionModel = new PermissionModel();
+        }
+
+        /* @phpstan-ignore-next-line */
+        $instance = new FlatAuthorization($groupModel, $permissionModel);
+
+        if ($userModel === null) {
+            $userModel = new UserModel();
+        }
+
+        /* @phpstan-ignore-next-line */
+        return $instance->setUserModel($userModel);
+    }
+}
diff --git a/app/Controllers/AuthController.php b/modules/Auth/Controllers/AuthController.php
similarity index 98%
rename from app/Controllers/AuthController.php
rename to modules/Auth/Controllers/AuthController.php
index 72db8009ace22d3972417eecb7995d83087ed7f5..6163e0b444f1f06c0d8c2063480643d4ba35afe9 100644
--- a/app/Controllers/AuthController.php
+++ b/modules/Auth/Controllers/AuthController.php
@@ -8,10 +8,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers;
+namespace Modules\Auth\Controllers;
 
-use App\Entities\User;
 use CodeIgniter\HTTP\RedirectResponse;
+use Modules\Auth\Entities\User;
 use Myth\Auth\Controllers\AuthController as MythAuthController;
 
 class AuthController extends MythAuthController
diff --git a/app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
similarity index 100%
rename from app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
rename to modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
diff --git a/modules/Auth/Database/Seeds/.gitkeep b/modules/Auth/Database/Seeds/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/modules/Auth/Database/Seeds/AuthSeeder.php b/modules/Auth/Database/Seeds/AuthSeeder.php
new file mode 100644
index 0000000000000000000000000000000000000000..2c2dc153b7767d3b577e4894e45f0b957ed9951e
--- /dev/null
+++ b/modules/Auth/Database/Seeds/AuthSeeder.php
@@ -0,0 +1,314 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Class PermissionSeeder Inserts permissions
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Auth\Database\Seeds;
+
+use CodeIgniter\Database\Seeder;
+
+class AuthSeeder extends Seeder
+{
+    /**
+     * @var array<string, string>[]
+     */
+    protected array $groups = [
+        [
+            'name' => 'superadmin',
+            'description' =>
+                'Somebody who has access to all the castopod instance features',
+        ],
+        [
+            'name' => 'podcast_admin',
+            'description' =>
+                'Somebody who has access to all the features within a given podcast',
+        ],
+    ];
+
+    /**
+     * Build permissions array as a list of:
+     *
+     * ``` context => [ [action, description], [action, description], ... ] ```
+     *
+     * @var array<string, array<string, string|array>[]>
+     */
+    protected array $permissions = [
+        'users' => [
+            [
+                'name' => 'create',
+                'description' => 'Create a user',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'list',
+                'description' => 'List all users',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'view',
+                'description' => 'View any user info',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'manage_authorizations',
+                'description' => 'Add or remove roles/permissions to a user',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'manage_bans',
+                'description' => 'Ban / unban a user',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'force_pass_reset',
+                'description' =>
+                    'Force a user to update his password upon next login',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'delete',
+                'description' =>
+                    'Delete user without removing him from database',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'delete_permanently',
+                'description' =>
+                    'Delete all occurrences of a user from the database',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+        'pages' => [
+            [
+                'name' => 'manage',
+                'description' => 'List / create / edit / delete pages',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+        'podcasts' => [
+            [
+                'name' => 'create',
+                'description' => 'Add a new podcast',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'import',
+                'description' => 'Import a new podcast from an external feed',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'list',
+                'description' => 'List all podcasts and their episodes',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'view',
+                'description' => 'View any podcast and their contributors list',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'delete',
+                'description' =>
+                    'Delete a podcast without removing it from database',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'delete_permanently',
+                'description' => 'Delete any podcast from the database',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+        'episodes' => [
+            [
+                'name' => 'list',
+                'description' => 'List all episodes of any podcast',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'view',
+                'description' => 'View any episode of any podcast',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+        'podcast' => [
+            [
+                'name' => 'view',
+                'description' => 'View a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'edit',
+                'description' => 'Edit a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'manage_contributors',
+                'description' =>
+                    'Add / remove contributors to a podcast and edit their roles',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'manage_platforms',
+                'description' => 'Set / remove platform links of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'manage_publications',
+                'description' =>
+                    'Publish / unpublish episodes & posts of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'interact_as',
+                'description' =>
+                    'Interact as the podcast to favourite / share or reply to posts.',
+                'has_permission' => ['podcast_admin'],
+            ],
+        ],
+        'podcast_episodes' => [
+            [
+                'name' => 'list',
+                'description' => 'List all episodes of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'view',
+                'description' => 'View any episode of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'create',
+                'description' => 'Add new episodes for a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'edit',
+                'description' => 'Edit an episode of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'delete',
+                'description' =>
+                    'Delete an episode of a podcast without removing it from the database',
+                'has_permission' => ['podcast_admin'],
+            ],
+            [
+                'name' => 'delete_permanently',
+                'description' =>
+                    'Delete all occurrences of an episode of a podcast from the database',
+                'has_permission' => ['podcast_admin'],
+            ],
+        ],
+        'person' => [
+            [
+                'name' => 'create',
+                'description' => 'Add a new person',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'list',
+                'description' => 'List all persons',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'view',
+                'description' => 'View any person',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'edit',
+                'description' => 'Edit a person',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'delete',
+                'description' =>
+                    'Delete permanently any person from the database',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+        'fediverse' => [
+            [
+                'name' => 'block_actors',
+                'description' =>
+                    'Block an activitypub actors from interacting with the instance.',
+                'has_permission' => ['superadmin'],
+            ],
+            [
+                'name' => 'block_domains',
+                'description' =>
+                    'Block an activitypub domains from interacting with the instance.',
+                'has_permission' => ['superadmin'],
+            ],
+        ],
+    ];
+
+    public function run(): void
+    {
+        $groupId = 0;
+        $dataGroups = [];
+        foreach ($this->groups as $group) {
+            $dataGroups[] = [
+                'id' => ++$groupId,
+                'name' => $group['name'],
+                'description' => $group['description'],
+            ];
+        }
+
+        // Map permissions to a format the `auth_permissions` table expects
+        $dataPermissions = [];
+        $dataGroupsPermissions = [];
+        $permissionId = 0;
+        foreach ($this->permissions as $context => $actions) {
+            foreach ($actions as $action) {
+                $dataPermissions[] = [
+                    'id' => ++$permissionId,
+                    'name' => $context . '-' . $action['name'],
+                    'description' => $action['description'],
+                ];
+
+                foreach ($action['has_permission'] as $role) {
+                    // link permission to specified groups
+                    $dataGroupsPermissions[] = [
+                        'group_id' => $this->getGroupIdByName($role, $dataGroups),
+                        'permission_id' => $permissionId,
+                    ];
+                }
+            }
+        }
+
+        $this->db
+            ->table('auth_permissions')
+            ->ignore(true)
+            ->insertBatch($dataPermissions);
+        $this->db
+            ->table('auth_groups')
+            ->ignore(true)
+            ->insertBatch($dataGroups);
+        $this->db
+            ->table('auth_groups_permissions')
+            ->ignore(true)
+            ->insertBatch($dataGroupsPermissions);
+    }
+
+    /**
+     * @param array<string, string|int>[] $dataGroups
+     */
+    public static function getGroupIdByName(string $name, array $dataGroups): ?int
+    {
+        foreach ($dataGroups as $group) {
+            if ($group['name'] === $name) {
+                return $group['id'];
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/app/Entities/User.php b/modules/Auth/Entities/User.php
similarity index 96%
rename from app/Entities/User.php
rename to modules/Auth/Entities/User.php
index f2b018c3d7ffd7d0f4e1c241789996d8e77a4d3e..9858db0bbe4c45817f243d0aecc9c3cd798c7457 100644
--- a/app/Entities/User.php
+++ b/modules/Auth/Entities/User.php
@@ -8,8 +8,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Entities;
+namespace Modules\Auth\Entities;
 
+use App\Entities\Podcast;
 use App\Models\PodcastModel;
 use Myth\Auth\Entities\User as MythAuthUser;
 use RuntimeException;
diff --git a/modules/Auth/Filters/.gitkeep b/modules/Auth/Filters/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/Filters/PermissionFilter.php b/modules/Auth/Filters/PermissionFilter.php
similarity index 99%
rename from app/Filters/PermissionFilter.php
rename to modules/Auth/Filters/PermissionFilter.php
index 69e20543c9f144a2e46ed8ae06b30f60445fc540..6f26e20b98abc52fa345909469349d56280d2110 100644
--- a/app/Filters/PermissionFilter.php
+++ b/modules/Auth/Filters/PermissionFilter.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Filters;
+namespace Modules\Auth\Filters;
 
 use App\Models\PodcastModel;
 use CodeIgniter\Filters\FilterInterface;
diff --git a/app/Views/auth/_layout.php b/modules/Auth/Views/_layout.php
similarity index 100%
rename from app/Views/auth/_layout.php
rename to modules/Auth/Views/_layout.php
diff --git a/app/Views/auth/emails/activation.php b/modules/Auth/Views/emails/activation.php
similarity index 100%
rename from app/Views/auth/emails/activation.php
rename to modules/Auth/Views/emails/activation.php
diff --git a/app/Views/auth/emails/forgot.php b/modules/Auth/Views/emails/forgot.php
similarity index 100%
rename from app/Views/auth/emails/forgot.php
rename to modules/Auth/Views/emails/forgot.php
diff --git a/app/Views/auth/forgot.php b/modules/Auth/Views/forgot.php
similarity index 100%
rename from app/Views/auth/forgot.php
rename to modules/Auth/Views/forgot.php
diff --git a/app/Views/auth/login.php b/modules/Auth/Views/login.php
similarity index 100%
rename from app/Views/auth/login.php
rename to modules/Auth/Views/login.php
diff --git a/app/Views/auth/register.php b/modules/Auth/Views/register.php
similarity index 100%
rename from app/Views/auth/register.php
rename to modules/Auth/Views/register.php
diff --git a/app/Views/auth/reset.php b/modules/Auth/Views/reset.php
similarity index 100%
rename from app/Views/auth/reset.php
rename to modules/Auth/Views/reset.php
diff --git a/app/Libraries/ActivityPub/Activities/AcceptActivity.php b/modules/Fediverse/Activities/AcceptActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/AcceptActivity.php
rename to modules/Fediverse/Activities/AcceptActivity.php
index 9dd1e04918e77bf895a5ef1ed3c5b623f5bc7c50..174460476f01ba60280da99d373b18bdb0b16818 100644
--- a/app/Libraries/ActivityPub/Activities/AcceptActivity.php
+++ b/modules/Fediverse/Activities/AcceptActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class AcceptActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/AnnounceActivity.php b/modules/Fediverse/Activities/AnnounceActivity.php
similarity index 87%
rename from app/Libraries/ActivityPub/Activities/AnnounceActivity.php
rename to modules/Fediverse/Activities/AnnounceActivity.php
index 75988ac635e758e12b0a1bb5356bd7f9b96e5560..c67839106636b3075f952bec8842fcc776bef7dc 100644
--- a/app/Libraries/ActivityPub/Activities/AnnounceActivity.php
+++ b/modules/Fediverse/Activities/AnnounceActivity.php
@@ -11,10 +11,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
-use ActivityPub\Entities\Post;
+use Modules\Fediverse\Core\Activity;
+use Modules\Fediverse\Entities\Post;
 
 class AnnounceActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/CreateActivity.php b/modules/Fediverse/Activities/CreateActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/CreateActivity.php
rename to modules/Fediverse/Activities/CreateActivity.php
index 42bc4d546036051c3d1452492fe2c0385a258b2a..5509263b8b1a3a64551ea4fff866785badf65d9e 100644
--- a/app/Libraries/ActivityPub/Activities/CreateActivity.php
+++ b/modules/Fediverse/Activities/CreateActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class CreateActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/DeleteActivity.php b/modules/Fediverse/Activities/DeleteActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/DeleteActivity.php
rename to modules/Fediverse/Activities/DeleteActivity.php
index 015b5310a265c2e798c4f7792d6d455caa0a1841..39aea24715a284b94b4edb45ff02d3224bd1ef2a 100644
--- a/app/Libraries/ActivityPub/Activities/DeleteActivity.php
+++ b/modules/Fediverse/Activities/DeleteActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class DeleteActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/FollowActivity.php b/modules/Fediverse/Activities/FollowActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/FollowActivity.php
rename to modules/Fediverse/Activities/FollowActivity.php
index ee8f7b4baf6db436ef4ea0190143cf8ca35ab2ca..d640771610e58832ab60ac6c299a78cf953b10c7 100644
--- a/app/Libraries/ActivityPub/Activities/FollowActivity.php
+++ b/modules/Fediverse/Activities/FollowActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class FollowActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/LikeActivity.php b/modules/Fediverse/Activities/LikeActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/LikeActivity.php
rename to modules/Fediverse/Activities/LikeActivity.php
index ce5634294a3f5438b4b9153d31040f03f53c1d60..1fe440462a6d67f868557966d4df803e5188efe4 100644
--- a/app/Libraries/ActivityPub/Activities/LikeActivity.php
+++ b/modules/Fediverse/Activities/LikeActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class LikeActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/Activities/UndoActivity.php b/modules/Fediverse/Activities/UndoActivity.php
similarity index 85%
rename from app/Libraries/ActivityPub/Activities/UndoActivity.php
rename to modules/Fediverse/Activities/UndoActivity.php
index 222b164ba00b8c4bfeeb47ec2eff2ae6b091b9a4..66935b05cfd36001bcb937e90ddb170c4a5a5e79 100644
--- a/app/Libraries/ActivityPub/Activities/UndoActivity.php
+++ b/modules/Fediverse/Activities/UndoActivity.php
@@ -11,9 +11,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Activities;
+namespace Modules\Fediverse\Activities;
 
-use ActivityPub\Core\Activity;
+use Modules\Fediverse\Core\Activity;
 
 class UndoActivity extends Activity
 {
diff --git a/app/Libraries/ActivityPub/ActivityRequest.php b/modules/Fediverse/ActivityRequest.php
similarity index 98%
rename from app/Libraries/ActivityPub/ActivityRequest.php
rename to modules/Fediverse/ActivityRequest.php
index 729c03e6f43a0e200751708ab8be9d6ec073d548..bfd7c37d93591359a96616ce9fdb7a2a8fa8dc4d 100644
--- a/app/Libraries/ActivityPub/ActivityRequest.php
+++ b/modules/Fediverse/ActivityRequest.php
@@ -8,14 +8,14 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub;
+namespace Modules\Fediverse;
 
-use ActivityPub\Core\Activity;
 use CodeIgniter\HTTP\CURLRequest;
 use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\HTTP\URI;
 use CodeIgniter\I18n\Time;
 use Config\Services;
+use Modules\Fediverse\Core\Activity;
 use phpseclib\Crypt\RSA;
 
 class ActivityRequest
diff --git a/app/Libraries/ActivityPub/Config/ActivityPub.php b/modules/Fediverse/Config/Fediverse.php
similarity index 76%
rename from app/Libraries/ActivityPub/Config/ActivityPub.php
rename to modules/Fediverse/Config/Fediverse.php
index 28e38bcfbb12f42dc2144f1c958cce4d6c8e1aab..fd7cb2e9b8890f2336b5707520159c5198f40172 100644
--- a/app/Libraries/ActivityPub/Config/ActivityPub.php
+++ b/modules/Fediverse/Config/Fediverse.php
@@ -8,13 +8,13 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Config;
+namespace Modules\Fediverse\Config;
 
-use ActivityPub\Objects\ActorObject;
-use ActivityPub\Objects\NoteObject;
 use CodeIgniter\Config\BaseConfig;
+use Modules\Fediverse\Objects\ActorObject;
+use Modules\Fediverse\Objects\NoteObject;
 
-class ActivityPub extends BaseConfig
+class Fediverse extends BaseConfig
 {
     /**
      * --------------------------------------------------------------------
@@ -30,11 +30,11 @@ class ActivityPub extends BaseConfig
      * Default avatar and cover images
      * --------------------------------------------------------------------
      */
-    public string $defaultAvatarImagePath = 'assets/images/avatar-default.jpg';
+    public string $defaultAvatarImagePath = 'media/castopod-avatar-default_thumbnail.jpg';
 
     public string $defaultAvatarImageMimetype = 'image/jpeg';
 
-    public string $defaultCoverImagePath = 'assets/images/cover-default.jpg';
+    public string $defaultCoverImagePath = 'media/castopod-cover-default.jpg';
 
     public string $defaultCoverImageMimetype = 'image/jpeg';
 
diff --git a/app/Libraries/ActivityPub/Config/Routes.php b/modules/Fediverse/Config/Routes.php
similarity index 97%
rename from app/Libraries/ActivityPub/Config/Routes.php
rename to modules/Fediverse/Config/Routes.php
index dd5dd0b1a1f307083ca0b3eddffff46bc8b3b12d..62ac3d2dcc48e3837efed142245a3dbbcbbfb7ac 100644
--- a/app/Libraries/ActivityPub/Config/Routes.php
+++ b/modules/Fediverse/Config/Routes.php
@@ -8,6 +8,8 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
+$routes = service('routes');
+
 $routes->addPlaceholder('actorUsername', '[a-zA-Z0-9\_]{1,32}');
 $routes->addPlaceholder(
     'uuid',
@@ -20,7 +22,7 @@ $routes->addPlaceholder('postAction', '\bfavourite|\breblog|\breply');
  */
 
 $routes->group('', [
-    'namespace' => 'ActivityPub\Controllers',
+    'namespace' => 'Modules\Fediverse\Controllers',
 ], function ($routes): void {
     // webfinger
     $routes->get('.well-known/webfinger', 'WebFingerController', [
diff --git a/app/Libraries/ActivityPub/Controllers/ActorController.php b/modules/Fediverse/Controllers/ActorController.php
similarity index 97%
rename from app/Libraries/ActivityPub/Controllers/ActorController.php
rename to modules/Fediverse/Controllers/ActorController.php
index 7a9408975fdefa95d328fdd1b37cea822e57a185..d208709df45d8566e38c957de7d896d56cd7a13e 100644
--- a/app/Libraries/ActivityPub/Controllers/ActorController.php
+++ b/modules/Fediverse/Controllers/ActorController.php
@@ -8,18 +8,18 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Controllers;
+namespace Modules\Fediverse\Controllers;
 
-use ActivityPub\Config\ActivityPub;
-use ActivityPub\Entities\Actor;
-use ActivityPub\Entities\Post;
-use ActivityPub\Objects\OrderedCollectionObject;
-use ActivityPub\Objects\OrderedCollectionPage;
 use CodeIgniter\Controller;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\I18n\Time;
+use Modules\Fediverse\Config\Fediverse;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Entities\Post;
+use Modules\Fediverse\Objects\OrderedCollectionObject;
+use Modules\Fediverse\Objects\OrderedCollectionPage;
 
 class ActorController extends Controller
 {
@@ -34,7 +34,7 @@ class ActorController extends Controller
 
     public function __construct()
     {
-        $this->config = config('ActivityPub');
+        $this->config = config('Fediverse');
     }
 
     public function _remap(string $method, string ...$params): mixed
diff --git a/app/Libraries/ActivityPub/Controllers/BlockController.php b/modules/Fediverse/Controllers/BlockController.php
similarity index 98%
rename from app/Libraries/ActivityPub/Controllers/BlockController.php
rename to modules/Fediverse/Controllers/BlockController.php
index 4111e08f0677a809d1a03219ef232827511298ef..76fad3f050940b46292d02bf661419d2c08c5018 100644
--- a/app/Libraries/ActivityPub/Controllers/BlockController.php
+++ b/modules/Fediverse/Controllers/BlockController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Controllers;
+namespace Modules\Fediverse\Controllers;
 
 use CodeIgniter\Controller;
 use CodeIgniter\HTTP\RedirectResponse;
diff --git a/app/Libraries/ActivityPub/Controllers/PostController.php b/modules/Fediverse/Controllers/PostController.php
similarity index 96%
rename from app/Libraries/ActivityPub/Controllers/PostController.php
rename to modules/Fediverse/Controllers/PostController.php
index 73de6f5e77c3b0146baa9695f31975a7849d07df..0db0ea67f4430ceb10e18eb5c3c2e940a553838e 100644
--- a/app/Libraries/ActivityPub/Controllers/PostController.php
+++ b/modules/Fediverse/Controllers/PostController.php
@@ -8,18 +8,17 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Controllers;
+namespace Modules\Fediverse\Controllers;
 
-use ActivityPub\Config\ActivityPub;
-use ActivityPub\Entities\Post;
-use ActivityPub\Objects\OrderedCollectionObject;
-use ActivityPub\Objects\OrderedCollectionPage;
 use CodeIgniter\Controller;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\Response;
 use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\I18n\Time;
+use Modules\Fediverse\Entities\Post;
+use Modules\Fediverse\Objects\OrderedCollectionObject;
+use Modules\Fediverse\Objects\OrderedCollectionPage;
 
 class PostController extends Controller
 {
@@ -34,7 +33,7 @@ class PostController extends Controller
 
     public function __construct()
     {
-        $this->config = config('ActivityPub');
+        $this->config = config('Fediverse');
     }
 
     public function _remap(string $method, string ...$params): mixed
diff --git a/app/Libraries/ActivityPub/Controllers/SchedulerController.php b/modules/Fediverse/Controllers/SchedulerController.php
similarity index 96%
rename from app/Libraries/ActivityPub/Controllers/SchedulerController.php
rename to modules/Fediverse/Controllers/SchedulerController.php
index 4b7928e23426ebd14d024230cb881269066338fe..43383a331a9851121da88e1ce8c9c335c22bfb74 100644
--- a/app/Libraries/ActivityPub/Controllers/SchedulerController.php
+++ b/modules/Fediverse/Controllers/SchedulerController.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Controllers;
+namespace Modules\Fediverse\Controllers;
 
 use CodeIgniter\Controller;
 
diff --git a/app/Libraries/ActivityPub/Controllers/WebFingerController.php b/modules/Fediverse/Controllers/WebFingerController.php
similarity index 90%
rename from app/Libraries/ActivityPub/Controllers/WebFingerController.php
rename to modules/Fediverse/Controllers/WebFingerController.php
index 7e6adab0463d504bb7e8d8008596dc2179052260..bd42a8dc0f1a8f5a393cca67ff33621a65c4daa8 100644
--- a/app/Libraries/ActivityPub/Controllers/WebFingerController.php
+++ b/modules/Fediverse/Controllers/WebFingerController.php
@@ -8,13 +8,13 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Controllers;
+namespace Modules\Fediverse\Controllers;
 
-use ActivityPub\WebFinger;
 use CodeIgniter\Controller;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\ResponseInterface;
 use Exception;
+use Modules\Fediverse\WebFinger;
 
 class WebFingerController extends Controller
 {
diff --git a/app/Libraries/ActivityPub/Core/AbstractObject.php b/modules/Fediverse/Core/AbstractObject.php
similarity index 97%
rename from app/Libraries/ActivityPub/Core/AbstractObject.php
rename to modules/Fediverse/Core/AbstractObject.php
index 97e761ecbd7e042109c0a91c89436e382f1baab0..4abd2c942a110ee4b242ef2d3bc49e45c5e55974 100644
--- a/app/Libraries/ActivityPub/Core/AbstractObject.php
+++ b/modules/Fediverse/Core/AbstractObject.php
@@ -12,7 +12,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Core;
+namespace Modules\Fediverse\Core;
 
 abstract class AbstractObject
 {
diff --git a/app/Libraries/ActivityPub/Core/Activity.php b/modules/Fediverse/Core/Activity.php
similarity index 94%
rename from app/Libraries/ActivityPub/Core/Activity.php
rename to modules/Fediverse/Core/Activity.php
index 4bc5de4adb71614ec1805195f50b1e930dcf0796..c9039a51f5e863d827437646de3cf6c0aabbc557 100644
--- a/app/Libraries/ActivityPub/Core/Activity.php
+++ b/modules/Fediverse/Core/Activity.php
@@ -11,7 +11,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Core;
+namespace Modules\Fediverse\Core;
 
 class Activity extends ObjectType
 {
diff --git a/app/Libraries/ActivityPub/Core/ObjectType.php b/modules/Fediverse/Core/ObjectType.php
similarity index 96%
rename from app/Libraries/ActivityPub/Core/ObjectType.php
rename to modules/Fediverse/Core/ObjectType.php
index dbe77da2dfa698e4818b76b848476112f6d72142..2a10ddd5a733296871e5cd19eb0cdd14c46c747d 100644
--- a/app/Libraries/ActivityPub/Core/ObjectType.php
+++ b/modules/Fediverse/Core/ObjectType.php
@@ -12,7 +12,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Core;
+namespace Modules\Fediverse\Core;
 
 class ObjectType extends AbstractObject
 {
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-010000_add_actors.php b/modules/Fediverse/Database/Migrations/2018-01-01-010000_add_actors.php
similarity index 98%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-010000_add_actors.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-010000_add_actors.php
index d247fbc70d72c32b4b6844641f4db7b2b925d756..95ad02d39dfcbc8c3a4ebfa8360f6f30f3ade763 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-010000_add_actors.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-010000_add_actors.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_posts.php b/modules/Fediverse/Database/Migrations/2018-01-01-020000_add_posts.php
similarity index 98%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_posts.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-020000_add_posts.php
index 05cc28b419600175be4f97073e339dcea81b7fe8..b3ffa0f070ef6dcad5235ae09e0c424ec7c5312b 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_posts.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-020000_add_posts.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
similarity index 97%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
index 1b9e99b597c6852c43bc317204966899ac0e0e4a..97405b71ea29cb35a31bc765ef5ccb92282f9f1a 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_favourites.php
similarity index 95%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-100000_add_favourites.php
index cfdc98ab01fde8a5ed102726f1909ad36dc90270..f1e4a190a92f0823d18fa607817728b5cb64d307 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_favourites.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_follows.php
similarity index 96%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-100000_add_follows.php
index 0d7550d07fffdf6f872256bce91a503691e8c74f..15992da5fafc80e2960ad19499ca947b2ad93ce5 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_follows.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_preview_cards.php
similarity index 97%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-100000_add_preview_cards.php
index 0eeb77fe88f3a5124880967a9e49fd25c8a65e1b..291a8974b8cd851f91560eff2eda1be8bb0fa248 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_preview_cards.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php b/modules/Fediverse/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php
similarity index 95%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php
index 9b677174ba0ba5a33dc04554a0bb26687733f5f1..8e58ceb3b95e4cf077727f929f18f8e47361c625 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-110000_add_posts_preview_cards.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php b/modules/Fediverse/Database/Migrations/2018-01-01-120000_add_blocked_domains.php
similarity index 94%
rename from app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php
rename to modules/Fediverse/Database/Migrations/2018-01-01-120000_add_blocked_domains.php
index dbbea7228c0e28dd24f53c4209556f9f7c28d280..9d0885905044c574bf84d9e93f9910c6bd0c2c7f 100644
--- a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-120000_add_blocked_domains.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Database\Migrations;
+namespace Modules\Fediverse\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/app/Libraries/ActivityPub/Entities/Activity.php b/modules/Fediverse/Entities/Activity.php
similarity index 98%
rename from app/Libraries/ActivityPub/Entities/Activity.php
rename to modules/Fediverse/Entities/Activity.php
index e60995c9394a3947a94937b3f718ffc087dade3a..458f97b777edcf7f5a54bc1910f9c91b6438397b 100644
--- a/app/Libraries/ActivityPub/Entities/Activity.php
+++ b/modules/Fediverse/Entities/Activity.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use Michalsn\Uuid\UuidEntity;
 use RuntimeException;
diff --git a/app/Libraries/ActivityPub/Entities/Actor.php b/modules/Fediverse/Entities/Actor.php
similarity index 91%
rename from app/Libraries/ActivityPub/Entities/Actor.php
rename to modules/Fediverse/Entities/Actor.php
index bf07cf53dee9ae604a5578c6f32002a59e2dee95..0b2f15b34459f00b89b6844841a7d41c17ad55e7 100644
--- a/app/Libraries/ActivityPub/Entities/Actor.php
+++ b/modules/Fediverse/Entities/Actor.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use CodeIgniter\Entity\Entity;
 use RuntimeException;
@@ -111,7 +111,7 @@ class Actor extends Entity
     public function getAvatarImageUrl(): string
     {
         if ($this->attributes['avatar_image_url'] === null) {
-            return base_url(config('ActivityPub')->defaultAvatarImagePath);
+            return base_url(config('Fediverse')->defaultAvatarImagePath);
         }
 
         return $this->attributes['avatar_image_url'];
@@ -120,7 +120,7 @@ class Actor extends Entity
     public function getAvatarImageMimetype(): string
     {
         if ($this->attributes['avatar_image_mimetype'] === null) {
-            return config('ActivityPub')->defaultAvatarImageMimetype;
+            return config('Fediverse')->defaultAvatarImageMimetype;
         }
 
         return $this->attributes['avatar_image_mimetype'];
@@ -129,7 +129,7 @@ class Actor extends Entity
     public function getCoverImageUrl(): string
     {
         if ($this->attributes['cover_image_url'] === null) {
-            return base_url(config('ActivityPub')->defaultCoverImagePath);
+            return base_url(config('Fediverse')->defaultCoverImagePath);
         }
 
         return $this->attributes['cover_image_url'];
@@ -138,7 +138,7 @@ class Actor extends Entity
     public function getCoverImageMimetype(): string
     {
         if ($this->attributes['cover_image_mimetype'] === null) {
-            return config('ActivityPub')->defaultCoverImageMimetype;
+            return config('Fediverse')->defaultCoverImageMimetype;
         }
 
         return $this->attributes['cover_image_mimetype'];
diff --git a/app/Libraries/ActivityPub/Entities/BlockedDomain.php b/modules/Fediverse/Entities/BlockedDomain.php
similarity index 91%
rename from app/Libraries/ActivityPub/Entities/BlockedDomain.php
rename to modules/Fediverse/Entities/BlockedDomain.php
index ca49556904f10f8d22873a6084e77d391b663a95..af3f721f49ae80527b8607f67ef218ba3c808112 100644
--- a/app/Libraries/ActivityPub/Entities/BlockedDomain.php
+++ b/modules/Fediverse/Entities/BlockedDomain.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/ActivityPub/Entities/Favourite.php b/modules/Fediverse/Entities/Favourite.php
similarity index 93%
rename from app/Libraries/ActivityPub/Entities/Favourite.php
rename to modules/Fediverse/Entities/Favourite.php
index b3d4028a7d0ade7f81504d601dfc61df528f4890..999af12f6d16b58752bfe47fa19d46bc6fc2909e 100644
--- a/app/Libraries/ActivityPub/Entities/Favourite.php
+++ b/modules/Fediverse/Entities/Favourite.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use Michalsn\Uuid\UuidEntity;
 
diff --git a/app/Libraries/ActivityPub/Entities/Follow.php b/modules/Fediverse/Entities/Follow.php
similarity index 92%
rename from app/Libraries/ActivityPub/Entities/Follow.php
rename to modules/Fediverse/Entities/Follow.php
index 5eb57258e7f13fa5fc48f9351ebbd95e643e36fb..46e4023ed33e83f7c549c2a435b2d52492a1abea 100644
--- a/app/Libraries/ActivityPub/Entities/Follow.php
+++ b/modules/Fediverse/Entities/Follow.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/ActivityPub/Entities/Post.php b/modules/Fediverse/Entities/Post.php
similarity index 99%
rename from app/Libraries/ActivityPub/Entities/Post.php
rename to modules/Fediverse/Entities/Post.php
index 7f33f0a9873ef917b084540343128f70834c5b24..145a01dd00cc161e254eaac840ec32a519dc0b1e 100644
--- a/app/Libraries/ActivityPub/Entities/Post.php
+++ b/modules/Fediverse/Entities/Post.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use CodeIgniter\I18n\Time;
 use Michalsn\Uuid\UuidEntity;
diff --git a/app/Libraries/ActivityPub/Entities/PreviewCard.php b/modules/Fediverse/Entities/PreviewCard.php
similarity index 96%
rename from app/Libraries/ActivityPub/Entities/PreviewCard.php
rename to modules/Fediverse/Entities/PreviewCard.php
index 8303dabacb35de81c7af99ba88c76c6654c52ec8..36bec4b60ef1f7554b95a9b05ccde05d9aaf9e68 100644
--- a/app/Libraries/ActivityPub/Entities/PreviewCard.php
+++ b/modules/Fediverse/Entities/PreviewCard.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Entities;
+namespace Modules\Fediverse\Entities;
 
 use CodeIgniter\Entity\Entity;
 
diff --git a/app/Libraries/ActivityPub/Filters/ActivityPubFilter.php b/modules/Fediverse/Filters/ActivityPubFilter.php
similarity index 97%
rename from app/Libraries/ActivityPub/Filters/ActivityPubFilter.php
rename to modules/Fediverse/Filters/ActivityPubFilter.php
index 370d6884f100d21bff79885af029fa8036377f9d..56c0c3327fb6b65cabb41caee3507c5eb93d4022 100644
--- a/app/Libraries/ActivityPub/Filters/ActivityPubFilter.php
+++ b/modules/Fediverse/Filters/ActivityPubFilter.php
@@ -2,9 +2,8 @@
 
 declare(strict_types=1);
 
-namespace ActivityPub\Filters;
+namespace Modules\Fediverse\Filters;
 
-use ActivityPub\HttpSignature;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\Filters\FilterInterface;
 use CodeIgniter\HTTP\RequestInterface;
@@ -12,6 +11,7 @@ use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\HTTP\URI;
 use Config\Services;
 use Exception;
+use Modules\Fediverse\HttpSignature;
 
 class ActivityPubFilter implements FilterInterface
 {
diff --git a/app/Libraries/ActivityPub/Helpers/activitypub_helper.php b/modules/Fediverse/Helpers/activitypub_helper.php
similarity index 98%
rename from app/Libraries/ActivityPub/Helpers/activitypub_helper.php
rename to modules/Fediverse/Helpers/activitypub_helper.php
index e72a8801b5db595f16e50e2f6062d136e59c5335..302e4ceb303c50f423df5524651be0dc0a7f2294 100644
--- a/app/Libraries/ActivityPub/Helpers/activitypub_helper.php
+++ b/modules/Fediverse/Helpers/activitypub_helper.php
@@ -8,14 +8,14 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-use ActivityPub\Activities\AcceptActivity;
-use ActivityPub\ActivityRequest;
-use ActivityPub\Entities\Actor;
-use ActivityPub\Entities\PreviewCard;
 use CodeIgniter\HTTP\Exceptions\HTTPException;
 use CodeIgniter\HTTP\URI;
 use Config\Database;
 use Essence\Essence;
+use Modules\Fediverse\Activities\AcceptActivity;
+use Modules\Fediverse\ActivityRequest;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Entities\PreviewCard;
 
 if (! function_exists('get_webfinger_data')) {
     /**
diff --git a/app/Libraries/ActivityPub/HttpSignature.php b/modules/Fediverse/HttpSignature.php
similarity index 99%
rename from app/Libraries/ActivityPub/HttpSignature.php
rename to modules/Fediverse/HttpSignature.php
index 046a1cf62d1bc710b4d0f944694896110ff09e71..1bd6412517f963605d2c3bcb15214e3b796afc54 100644
--- a/app/Libraries/ActivityPub/HttpSignature.php
+++ b/modules/Fediverse/HttpSignature.php
@@ -12,7 +12,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub;
+namespace Modules\Fediverse;
 
 use CodeIgniter\HTTP\IncomingRequest;
 use CodeIgniter\I18n\Time;
diff --git a/app/Libraries/ActivityPub/Models/ActivityModel.php b/modules/Fediverse/Models/ActivityModel.php
similarity index 95%
rename from app/Libraries/ActivityPub/Models/ActivityModel.php
rename to modules/Fediverse/Models/ActivityModel.php
index 708d1dc270990d2989b25e9b9d3d3018f603769b..0286bd7853ce36c1bffa4cd764ca296bdcd2de70 100644
--- a/app/Libraries/ActivityPub/Models/ActivityModel.php
+++ b/modules/Fediverse/Models/ActivityModel.php
@@ -8,13 +8,13 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Entities\Activity;
 use CodeIgniter\Database\BaseResult;
 use CodeIgniter\I18n\Time;
 use DateTimeInterface;
 use Michalsn\Uuid\UuidModel;
+use Modules\Fediverse\Entities\Activity;
 
 class ActivityModel extends UuidModel
 {
@@ -67,7 +67,7 @@ class ActivityModel extends UuidModel
     public function getActivityById(string $activityId): ?Activity
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "activity#{$activityId}";
         if (! ($found = cache($cacheName))) {
             $found = $this->find($activityId);
diff --git a/app/Libraries/ActivityPub/Models/ActorModel.php b/modules/Fediverse/Models/ActorModel.php
similarity index 93%
rename from app/Libraries/ActivityPub/Models/ActorModel.php
rename to modules/Fediverse/Models/ActorModel.php
index 48e7ff26c52f5f45973e1d699e520db79a2c1e6a..9bab67cb71c9d5278cab1afa8bcdf98dbe039bf0 100644
--- a/app/Libraries/ActivityPub/Models/ActorModel.php
+++ b/modules/Fediverse/Models/ActorModel.php
@@ -8,11 +8,11 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Entities\Actor;
 use CodeIgniter\Events\Events;
 use CodeIgniter\Model;
+use Modules\Fediverse\Entities\Actor;
 
 class ActorModel extends Model
 {
@@ -62,7 +62,7 @@ class ActorModel extends Model
 
     public function getActorById(int $id): ?Actor
     {
-        $cacheName = config('ActivityPub')
+        $cacheName = config('Fediverse')
             ->cachePrefix . "actor#{$id}";
         if (! ($found = cache($cacheName))) {
             $found = $this->find($id);
@@ -107,7 +107,7 @@ class ActorModel extends Model
     {
         $hashedActorUri = md5($actorUri);
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "actor-{$hashedActorUri}";
         if (! ($found = cache($cacheName))) {
             $found = $this->where('uri', $actorUri)
@@ -126,7 +126,7 @@ class ActorModel extends Model
     public function getFollowers(int $actorId): array
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "actor#{$actorId}_followers";
         if (! ($found = cache($cacheName))) {
             $found = $this->join('activitypub_follows', 'activitypub_follows.actor_id = id', 'inner')
@@ -159,7 +159,7 @@ class ActorModel extends Model
      */
     public function getBlockedActors(): array
     {
-        $cacheName = config('ActivityPub')
+        $cacheName = config('Fediverse')
             ->cachePrefix . 'blocked_actors';
         if (! ($found = cache($cacheName))) {
             $found = $this->where('is_blocked', 1)
@@ -174,7 +174,7 @@ class ActorModel extends Model
 
     public function blockActor(int $actorId): void
     {
-        $prefix = config('ActivityPub')
+        $prefix = config('Fediverse')
             ->cachePrefix;
         cache()
             ->delete($prefix . 'blocked_actors');
@@ -190,7 +190,7 @@ class ActorModel extends Model
 
     public function unblockActor(int $actorId): void
     {
-        $prefix = config('ActivityPub')
+        $prefix = config('Fediverse')
             ->cachePrefix;
         cache()
             ->delete($prefix . 'blocked_actors');
@@ -206,7 +206,7 @@ class ActorModel extends Model
 
     public function clearCache(Actor $actor): void
     {
-        $cachePrefix = config('ActivityPub')
+        $cachePrefix = config('Fediverse')
             ->cachePrefix;
         $hashedActorUri = md5($actor->uri);
         $cacheDomain = str_replace(':', '', $actor->domain);
diff --git a/app/Libraries/ActivityPub/Models/BlockedDomainModel.php b/modules/Fediverse/Models/BlockedDomainModel.php
similarity index 93%
rename from app/Libraries/ActivityPub/Models/BlockedDomainModel.php
rename to modules/Fediverse/Models/BlockedDomainModel.php
index d5a3cccc5f1914dd02decd7ffc9a55158ae8f761..8ffd8f37311293a039a9f325d6adcd3276238a9d 100644
--- a/app/Libraries/ActivityPub/Models/BlockedDomainModel.php
+++ b/modules/Fediverse/Models/BlockedDomainModel.php
@@ -8,12 +8,12 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Entities\BlockedDomain;
 use CodeIgniter\Database\BaseResult;
 use CodeIgniter\Events\Events;
 use CodeIgniter\Model;
+use Modules\Fediverse\Entities\BlockedDomain;
 
 class BlockedDomainModel extends Model
 {
@@ -56,7 +56,7 @@ class BlockedDomainModel extends Model
      */
     public function getBlockedDomains(): array
     {
-        $cacheName = config('ActivityPub')
+        $cacheName = config('Fediverse')
             ->cachePrefix . 'blocked_domains';
         if (! ($found = cache($cacheName))) {
             $found = $this->findAll();
@@ -71,7 +71,7 @@ class BlockedDomainModel extends Model
     {
         $hashedDomainName = md5($name);
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix .
             "domain#{$hashedDomainName}_isBlocked";
         if (! ($found = cache($cacheName))) {
@@ -87,7 +87,7 @@ class BlockedDomainModel extends Model
     public function blockDomain(string $name): int | bool
     {
         $hashedDomain = md5($name);
-        $prefix = config('ActivityPub')
+        $prefix = config('Fediverse')
             ->cachePrefix;
         cache()
             ->delete($prefix . "domain#{$hashedDomain}_isBlocked");
@@ -119,7 +119,7 @@ class BlockedDomainModel extends Model
     public function unblockDomain(string $name): BaseResult | bool
     {
         $hashedDomain = md5($name);
-        $prefix = config('ActivityPub')
+        $prefix = config('Fediverse')
             ->cachePrefix;
         cache()
             ->delete($prefix . "domain#{$hashedDomain}_isBlocked");
diff --git a/app/Libraries/ActivityPub/Models/FavouriteModel.php b/modules/Fediverse/Models/FavouriteModel.php
similarity index 94%
rename from app/Libraries/ActivityPub/Models/FavouriteModel.php
rename to modules/Fediverse/Models/FavouriteModel.php
index c0c4e9f1c251dac0828ab9e80d68b03d6add7575..1ddca50efb52709870f03f9e4c43adb59e882b29 100644
--- a/app/Libraries/ActivityPub/Models/FavouriteModel.php
+++ b/modules/Fediverse/Models/FavouriteModel.php
@@ -8,15 +8,15 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Activities\LikeActivity;
-use ActivityPub\Activities\UndoActivity;
-use ActivityPub\Entities\Actor;
-use ActivityPub\Entities\Favourite;
-use ActivityPub\Entities\Post;
 use CodeIgniter\Events\Events;
 use Michalsn\Uuid\UuidModel;
+use Modules\Fediverse\Activities\LikeActivity;
+use Modules\Fediverse\Activities\UndoActivity;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Entities\Favourite;
+use Modules\Fediverse\Entities\Post;
 
 class FavouriteModel extends UuidModel
 {
diff --git a/app/Libraries/ActivityPub/Models/FollowModel.php b/modules/Fediverse/Models/FollowModel.php
similarity index 95%
rename from app/Libraries/ActivityPub/Models/FollowModel.php
rename to modules/Fediverse/Models/FollowModel.php
index 6622f273668c8be0331f87f0549ac7664fc5bc66..b0d7c2e70b79aa5031540db20745bec7e308055f 100644
--- a/app/Libraries/ActivityPub/Models/FollowModel.php
+++ b/modules/Fediverse/Models/FollowModel.php
@@ -8,18 +8,18 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Activities\FollowActivity;
-use ActivityPub\Activities\UndoActivity;
-use ActivityPub\Entities\Actor;
-use ActivityPub\Entities\Follow;
 use CodeIgniter\Database\Exceptions\DatabaseException;
 use CodeIgniter\Events\Events;
 use CodeIgniter\I18n\Time;
 use CodeIgniter\Model;
 use Exception;
 use InvalidArgumentException;
+use Modules\Fediverse\Activities\FollowActivity;
+use Modules\Fediverse\Activities\UndoActivity;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Entities\Follow;
 
 class FollowModel extends Model
 {
diff --git a/app/Libraries/ActivityPub/Models/PostModel.php b/modules/Fediverse/Models/PostModel.php
similarity index 96%
rename from app/Libraries/ActivityPub/Models/PostModel.php
rename to modules/Fediverse/Models/PostModel.php
index f7dc644a6ffadf25957dabd1da9cbfb4b5a51cf2..dcb2de6f342a1683f1e62d4acbc5b35309b2c7fa 100644
--- a/app/Libraries/ActivityPub/Models/PostModel.php
+++ b/modules/Fediverse/Models/PostModel.php
@@ -8,15 +8,8 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
-
-use ActivityPub\Activities\AnnounceActivity;
-use ActivityPub\Activities\CreateActivity;
-use ActivityPub\Activities\DeleteActivity;
-use ActivityPub\Activities\UndoActivity;
-use ActivityPub\Entities\Actor;
-use ActivityPub\Entities\Post;
-use ActivityPub\Objects\TombstoneObject;
+namespace Modules\Fediverse\Models;
+
 use CodeIgniter\Database\BaseResult;
 use CodeIgniter\Database\Query;
 use CodeIgniter\Events\Events;
@@ -24,6 +17,13 @@ use CodeIgniter\HTTP\URI;
 use CodeIgniter\I18n\Time;
 use Exception;
 use Michalsn\Uuid\UuidModel;
+use Modules\Fediverse\Activities\AnnounceActivity;
+use Modules\Fediverse\Activities\CreateActivity;
+use Modules\Fediverse\Activities\DeleteActivity;
+use Modules\Fediverse\Activities\UndoActivity;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Entities\Post;
+use Modules\Fediverse\Objects\TombstoneObject;
 
 class PostModel extends UuidModel
 {
@@ -91,7 +91,7 @@ class PostModel extends UuidModel
 
     public function getPostById(string $postId): ?Post
     {
-        $cacheName = config('ActivityPub')
+        $cacheName = config('Fediverse')
             ->cachePrefix . "post#{$postId}";
         if (! ($found = cache($cacheName))) {
             $found = $this->find($postId);
@@ -107,7 +107,7 @@ class PostModel extends UuidModel
     {
         $hashedPostUri = md5($postUri);
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "post-{$hashedPostUri}";
         if (! ($found = cache($cacheName))) {
             $found = $this->where('uri', $postUri)
@@ -128,7 +128,7 @@ class PostModel extends UuidModel
     public function getActorPublishedPosts(int $actorId): array
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix .
             "actor#{$actorId}_published_posts";
         if (! ($found = cache($cacheName))) {
@@ -177,7 +177,7 @@ class PostModel extends UuidModel
     public function getPostReplies(string $postId, bool $withBlocked = false): array
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix .
             "post#{$postId}_replies" .
             ($withBlocked ? '_withBlocked' : '');
@@ -209,7 +209,7 @@ class PostModel extends UuidModel
     public function getPostReblogs(string $postId): array
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "post#{$postId}_reblogs";
 
         if (! ($found = cache($cacheName))) {
@@ -281,7 +281,7 @@ class PostModel extends UuidModel
             $post->uri = url_to('post', $post->actor->username, $newPostId);
 
             $createActivity = new CreateActivity();
-            $noteObjectClass = config('ActivityPub')
+            $noteObjectClass = config('Fediverse')
                 ->noteObject;
             $createActivity
                 ->set('actor', $post->actor->uri)
@@ -590,7 +590,7 @@ class PostModel extends UuidModel
 
     public function clearCache(Post $post): void
     {
-        $cachePrefix = config('ActivityPub')
+        $cachePrefix = config('Fediverse')
             ->cachePrefix;
 
         $hashedPostUri = md5($post->uri);
diff --git a/app/Libraries/ActivityPub/Models/PreviewCardModel.php b/modules/Fediverse/Models/PreviewCardModel.php
similarity index 89%
rename from app/Libraries/ActivityPub/Models/PreviewCardModel.php
rename to modules/Fediverse/Models/PreviewCardModel.php
index 08879ff7953262726186f5ef190157f23a557848..bfbd00f7c8adbf04aa8a49c5b675db46860afe36 100644
--- a/app/Libraries/ActivityPub/Models/PreviewCardModel.php
+++ b/modules/Fediverse/Models/PreviewCardModel.php
@@ -8,11 +8,11 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Models;
+namespace Modules\Fediverse\Models;
 
-use ActivityPub\Entities\PreviewCard;
 use CodeIgniter\Database\BaseResult;
 use CodeIgniter\Model;
+use Modules\Fediverse\Entities\PreviewCard;
 
 class PreviewCardModel extends Model
 {
@@ -57,7 +57,7 @@ class PreviewCardModel extends Model
     {
         $hashedPreviewCardUrl = md5($url);
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix .
             "preview_card-{$hashedPreviewCardUrl}";
         if (! ($found = cache($cacheName))) {
@@ -73,7 +73,7 @@ class PreviewCardModel extends Model
     public function getPostPreviewCard(string $postId): ?PreviewCard
     {
         $cacheName =
-            config('ActivityPub')
+            config('Fediverse')
                 ->cachePrefix . "post#{$postId}_preview_card";
         if (! ($found = cache($cacheName))) {
             $found = $this->join(
@@ -95,7 +95,7 @@ class PreviewCardModel extends Model
     {
         $hashedPreviewCardUrl = md5($url);
         cache()
-            ->delete(config('ActivityPub') ->cachePrefix . "preview_card-{$hashedPreviewCardUrl}");
+            ->delete(config('Fediverse') ->cachePrefix . "preview_card-{$hashedPreviewCardUrl}");
 
         return $this->delete($id);
     }
diff --git a/app/Libraries/ActivityPub/Objects/ActorObject.php b/modules/Fediverse/Objects/ActorObject.php
similarity index 94%
rename from app/Libraries/ActivityPub/Objects/ActorObject.php
rename to modules/Fediverse/Objects/ActorObject.php
index b3280650f4904d26335c8c355e0d73fbc9e173e3..0914f3094ee6e03f44f2363793dce46fb8ce2615 100644
--- a/app/Libraries/ActivityPub/Objects/ActorObject.php
+++ b/modules/Fediverse/Objects/ActorObject.php
@@ -8,10 +8,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Objects;
+namespace Modules\Fediverse\Objects;
 
-use ActivityPub\Core\ObjectType;
-use ActivityPub\Entities\Actor;
+use Modules\Fediverse\Core\ObjectType;
+use Modules\Fediverse\Entities\Actor;
 
 class ActorObject extends ObjectType
 {
diff --git a/app/Libraries/ActivityPub/Objects/NoteObject.php b/modules/Fediverse/Objects/NoteObject.php
similarity index 83%
rename from app/Libraries/ActivityPub/Objects/NoteObject.php
rename to modules/Fediverse/Objects/NoteObject.php
index 5588f9bb647ece80f0c9f55ea624c52d1ee8dc08..eee250d344842ff8f8fa52ee777e04e9c6b2ab0e 100644
--- a/app/Libraries/ActivityPub/Objects/NoteObject.php
+++ b/modules/Fediverse/Objects/NoteObject.php
@@ -12,10 +12,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Objects;
+namespace Modules\Fediverse\Objects;
 
-use ActivityPub\Core\ObjectType;
-use ActivityPub\Entities\Post;
+use Modules\Fediverse\Core\ObjectType;
+use Modules\Fediverse\Entities\Post;
 
 class NoteObject extends ObjectType
 {
@@ -27,7 +27,10 @@ class NoteObject extends ObjectType
 
     protected string $replies;
 
-    public function __construct(Post $post)
+    /**
+     * @param Post $post
+     */
+    public function __construct($post)
     {
         $this->id = $post->uri;
 
diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php b/modules/Fediverse/Objects/OrderedCollectionObject.php
similarity index 93%
rename from app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
rename to modules/Fediverse/Objects/OrderedCollectionObject.php
index 64e64c69117d15c84b294e58f0a4ae8bc61774e6..ddfb0cbbe56457b7a971969365227ce850052006 100644
--- a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
+++ b/modules/Fediverse/Objects/OrderedCollectionObject.php
@@ -10,10 +10,10 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Objects;
+namespace Modules\Fediverse\Objects;
 
-use ActivityPub\Core\ObjectType;
 use CodeIgniter\Pager\Pager;
+use Modules\Fediverse\Core\ObjectType;
 
 class OrderedCollectionObject extends ObjectType
 {
diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php b/modules/Fediverse/Objects/OrderedCollectionPage.php
similarity index 97%
rename from app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php
rename to modules/Fediverse/Objects/OrderedCollectionPage.php
index de177ebd56dada8c9d416348678c57a71246751a..ae4f939951f3beee3d6f79339e3f74de71c12c1a 100644
--- a/app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php
+++ b/modules/Fediverse/Objects/OrderedCollectionPage.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Objects;
+namespace Modules\Fediverse\Objects;
 
 use CodeIgniter\Pager\Pager;
 
diff --git a/app/Libraries/ActivityPub/Objects/TombstoneObject.php b/modules/Fediverse/Objects/TombstoneObject.php
similarity index 77%
rename from app/Libraries/ActivityPub/Objects/TombstoneObject.php
rename to modules/Fediverse/Objects/TombstoneObject.php
index aca1ac1e7fef8fd505245450cab0595ee17061a6..337424ff5dd4a7d82616163be8f4edb66f2279cb 100644
--- a/app/Libraries/ActivityPub/Objects/TombstoneObject.php
+++ b/modules/Fediverse/Objects/TombstoneObject.php
@@ -8,9 +8,9 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub\Objects;
+namespace Modules\Fediverse\Objects;
 
-use ActivityPub\Core\ObjectType;
+use Modules\Fediverse\Core\ObjectType;
 
 class TombstoneObject extends ObjectType
 {
diff --git a/app/Libraries/ActivityPub/WebFinger.php b/modules/Fediverse/WebFinger.php
similarity index 98%
rename from app/Libraries/ActivityPub/WebFinger.php
rename to modules/Fediverse/WebFinger.php
index e472ff246111a882c54ff8315f7666f02f34f94e..592ae92c551318383fde583ad1b5aa69bc9e7cd4 100644
--- a/app/Libraries/ActivityPub/WebFinger.php
+++ b/modules/Fediverse/WebFinger.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace ActivityPub;
+namespace Modules\Fediverse;
 
 use Exception;
 
diff --git a/modules/Install/Config/Install.php b/modules/Install/Config/Install.php
new file mode 100644
index 0000000000000000000000000000000000000000..db5b203081247bf407580df7f184d76c84567876
--- /dev/null
+++ b/modules/Install/Config/Install.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Install\Config;
+
+use CodeIgniter\Config\BaseConfig;
+
+class Install extends BaseConfig
+{
+    /**
+     * --------------------------------------------------------------------------
+     * Install gateway
+     * --------------------------------------------------------------------------
+     * Defines a base route for instance installation
+     */
+    public string $gateway = 'cp-install';
+}
diff --git a/modules/Install/Config/Routes.php b/modules/Install/Config/Routes.php
new file mode 100644
index 0000000000000000000000000000000000000000..8891055bd1a8607b31fc0f132fbcbb8fe649fa8b
--- /dev/null
+++ b/modules/Install/Config/Routes.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Install\Config;
+
+$routes = service('routes');
+
+// Install Wizard routes
+$routes->group(
+    config('Install')
+        ->gateway,
+    [
+        'namespace' => 'Modules\Install\Controllers',
+    ],
+    function ($routes): void {
+        $routes->get('/', 'InstallController', [
+            'as' => 'install',
+        ]);
+        $routes->post('instance-config', 'InstallController::attemptInstanceConfig', [
+            'as' => 'instance-config',
+        ]);
+        $routes->post('database-config', 'InstallController::attemptDatabaseConfig', [
+            'as' => 'database-config',
+        ]);
+        $routes->post('cache-config', 'InstallController::attemptCacheConfig', [
+            'as' => 'cache-config',
+        ]);
+        $routes->post(
+            'create-superadmin',
+            'InstallController::attemptCreateSuperAdmin',
+            [
+                'as' => 'create-superadmin',
+            ],
+        );
+    }
+);
diff --git a/app/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php
similarity index 90%
rename from app/Controllers/InstallController.php
rename to modules/Install/Controllers/InstallController.php
index 09bf9044c665d74fd6d743a3d2d75c76e57f5e33..1b0e8cada1e7246721b3c52954a4bb21ed5cd33f 100644
--- a/app/Controllers/InstallController.php
+++ b/modules/Install/Controllers/InstallController.php
@@ -8,9 +8,8 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Controllers;
+namespace Modules\Install\Controllers;
 
-use App\Entities\User;
 use App\Models\UserModel;
 use CodeIgniter\Controller;
 use CodeIgniter\Database\Exceptions\DatabaseException;
@@ -22,6 +21,7 @@ use Config\Database;
 use Config\Services;
 use Dotenv\Dotenv;
 use Dotenv\Exception\ValidationException;
+use Modules\Auth\Entities\User;
 use Psr\Log\LoggerInterface;
 use Throwable;
 
@@ -58,7 +58,7 @@ class InstallController extends Controller
                 fclose($envFile);
             } catch (Throwable) {
                 // Could not create the .env file, redirect to a view with instructions on how to add it manually
-                return view('install/manual_config');
+                return view('Modules\Install\Views\manual_config');
             }
         }
 
@@ -69,7 +69,7 @@ class InstallController extends Controller
         // Check if the created .env file is writable to continue install process
         if (is_really_writable(ROOTPATH . '.env')) {
             try {
-                $dotenv->required(['app.baseURL', 'app.adminGateway', 'app.authGateway']);
+                $dotenv->required(['app.baseURL', 'admin.gateway', 'auth.gateway']);
             } catch (ValidationException) {
                 // form to input instance configuration
                 return $this->instanceConfig();
@@ -96,8 +96,8 @@ class InstallController extends Controller
             try {
                 $dotenv->required([
                     'app.baseURL',
-                    'app.adminGateway',
-                    'app.authGateway',
+                    'admin.gateway',
+                    'auth.gateway',
                     'database.default.hostname',
                     'database.default.database',
                     'database.default.username',
@@ -106,7 +106,7 @@ class InstallController extends Controller
                     'cache.handler',
                 ]);
             } catch (ValidationException) {
-                return view('install/manual_config');
+                return view('Modules\Install\Views\manual_config');
             }
         }
 
@@ -127,7 +127,7 @@ class InstallController extends Controller
             session()
                 ->setFlashdata('error', lang('Install.messages.databaseConnectError'));
 
-            return view('install/database_config');
+            return view('Modules\Install\Views\database_config');
         }
 
         // migrate if no user has been created
@@ -141,7 +141,7 @@ class InstallController extends Controller
 
     public function instanceConfig(): string
     {
-        return view('install/instance_config');
+        return view('Modules\Install\Views\instance_config');
     }
 
     public function attemptInstanceConfig(): RedirectResponse
@@ -155,7 +155,7 @@ class InstallController extends Controller
 
         if (! $this->validate($rules)) {
             return redirect()
-                ->to((host_url() === null ? config('App') ->baseURL : host_url()) . config('App')->installGateway)
+                ->to((host_url() === null ? config('App') ->baseURL : host_url()) . config('Install')->gateway)
                 ->withInput()
                 ->with('errors', $this->validator->getErrors());
         }
@@ -166,19 +166,19 @@ class InstallController extends Controller
             'app.baseURL' => $baseUrl,
             'app.mediaBaseURL' =>
                 $mediaBaseUrl === '' ? $baseUrl : $mediaBaseUrl,
-            'app.adminGateway' => $this->request->getPost('admin_gateway'),
-            'app.authGateway' => $this->request->getPost('auth_gateway'),
+            'admin.gateway' => $this->request->getPost('admin_gateway'),
+            'auth.gateway' => $this->request->getPost('auth_gateway'),
         ]);
 
         helper('text');
 
         // redirect to full install url with new baseUrl input
-        return redirect()->to(reduce_double_slashes($baseUrl . '/' . config('App')->installGateway));
+        return redirect()->to(reduce_double_slashes($baseUrl . '/' . config('Install')->gateway));
     }
 
     public function databaseConfig(): string
     {
-        return view('install/database_config');
+        return view('Modules\Install\Views\database_config');
     }
 
     public function attemptDatabaseConfig(): RedirectResponse
@@ -210,7 +210,7 @@ class InstallController extends Controller
 
     public function cacheConfig(): string
     {
-        return view('install/cache_config');
+        return view('Modules\Install\Views\cache_config');
     }
 
     public function attemptCacheConfig(): RedirectResponse
@@ -242,12 +242,14 @@ class InstallController extends Controller
 
         $migrations->setNamespace('Myth\Auth')
             ->latest();
-        $migrations->setNamespace('ActivityPub')
-            ->latest();
-        $migrations->setNamespace('Analytics')
+        $migrations->setNamespace('Modules\Fediverse')
             ->latest();
         $migrations->setNamespace(APP_NAMESPACE)
             ->latest();
+        $migrations->setNamespace('Modules\Auth')
+            ->latest();
+        $migrations->setNamespace('Modules\Analytics')
+            ->latest();
     }
 
     /**
@@ -266,7 +268,7 @@ class InstallController extends Controller
      */
     public function createSuperAdmin(): string
     {
-        return view('install/create_superadmin');
+        return view('Modules\Install\Views\create_superadmin');
     }
 
     /**
diff --git a/app/Views/install/_layout.php b/modules/Install/Views/_layout.php
similarity index 100%
rename from app/Views/install/_layout.php
rename to modules/Install/Views/_layout.php
diff --git a/app/Views/install/cache_config.php b/modules/Install/Views/cache_config.php
similarity index 95%
rename from app/Views/install/cache_config.php
rename to modules/Install/Views/cache_config.php
index 0f54c0fb840901b25870e7c893811e5713522029..4c15821579dd51edf16e153b2934f7369cf81b9d 100644
--- a/app/Views/install/cache_config.php
+++ b/modules/Install/Views/cache_config.php
@@ -1,4 +1,4 @@
-<?= $this->extend('install/_layout') ?>
+<?= $this->extend('Modules\Install\Views\_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/install/create_superadmin.php b/modules/Install/Views/create_superadmin.php
similarity index 96%
rename from app/Views/install/create_superadmin.php
rename to modules/Install/Views/create_superadmin.php
index 4363cfd96884a09ddd3a3270b3095fad4a994142..3af6084cdc95c96c77be59c9c603c77c4c5e0343 100644
--- a/app/Views/install/create_superadmin.php
+++ b/modules/Install/Views/create_superadmin.php
@@ -1,4 +1,4 @@
-<?= $this->extend('install/_layout') ?>
+<?= $this->extend('Modules\Install\Views\_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/install/database_config.php b/modules/Install/Views/database_config.php
similarity index 97%
rename from app/Views/install/database_config.php
rename to modules/Install/Views/database_config.php
index 5a887dd65b97e1539ecd5326c3fb6d766c8f0723..9dbb0839410110f5d763e39ccd5d97bbc755759d 100644
--- a/app/Views/install/database_config.php
+++ b/modules/Install/Views/database_config.php
@@ -1,4 +1,4 @@
-<?= $this->extend('install/_layout') ?>
+<?= $this->extend('Modules\Install\Views\_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/install/instance_config.php b/modules/Install/Views/instance_config.php
similarity index 89%
rename from app/Views/install/instance_config.php
rename to modules/Install/Views/instance_config.php
index 18e3e63ee213a96ca12f05bbe4fead1d1bd25fab..d8a000327d95eec2cc40cc807ee21d7b75a3540f 100644
--- a/app/Views/install/instance_config.php
+++ b/modules/Install/Views/instance_config.php
@@ -1,9 +1,9 @@
-<?= $this->extend('install/_layout') ?>
+<?= $this->extend('Modules\Install\Views\_layout') ?>
 
 <?= $this->section('content') ?>
 
 <form action="<?= '/' .
-    config('App')->installGateway .
+    config('Install')->gateway .
     '/instance-config' ?>" class="flex flex-col w-full max-w-sm" method="post" accept-charset="utf-8">
 <?= csrf_field() ?>
 
@@ -47,7 +47,7 @@
     'id' => 'admin_gateway',
     'name' => 'admin_gateway',
     'class' => 'form-input mb-4',
-    'value' => old('admin_gateway', config('App')->adminGateway),
+    'value' => old('admin_gateway', config('Admin')->gateway),
     'required' => 'required',
 ]) ?>
 
@@ -61,7 +61,7 @@
     'id' => 'auth_gateway',
     'name' => 'auth_gateway',
     'class' => 'form-input mb-6',
-    'value' => old('auth_gateway', config('App')->authGateway),
+    'value' => old('auth_gateway', config('Auth')->gateway),
     'required' => 'required',
 ]) ?>
 
diff --git a/app/Views/install/manual_config.php b/modules/Install/Views/manual_config.php
similarity index 89%
rename from app/Views/install/manual_config.php
rename to modules/Install/Views/manual_config.php
index 26fb5ed12d4adaf009644b19e912eb0d51c6e605..3ed77c74e626b89f21473cae527a652a34dca67e 100644
--- a/app/Views/install/manual_config.php
+++ b/modules/Install/Views/manual_config.php
@@ -1,4 +1,4 @@
-<?= $this->extend('install/_layout') ?>
+<?= $this->extend('Modules\Install\Views\_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/phpstan.neon b/phpstan.neon
index ac3d788d6c1e01d5ba3babfe8c1d772b8287fa3a..83d920db3c3a1b9ee7e195d8ff00ed9a664b1c40 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -8,24 +8,21 @@ parameters:
         - vendor/codeigniter4/codeigniter4/system/Test/bootstrap.php
     scanDirectories:
         - app/Helpers
+        - modules/Analytics/Helpers
+        - modules/Fediverse/Helpers
         - vendor/codeigniter4/codeigniter4/system/Helpers
         - vendor/myth/auth/src/Helpers
     excludes_analyse:
-        - app/Config/Routes.php
         - app/Libraries/Router.php
-        - app/Libraries/ActivityPub/Config/Routes.php
-        - app/Libraries/Analytics/Config/Routes.php
         - app/Views/*
     ignoreErrors:
         - '#This property type might be inlined to PHP. Do you have confidence it is correct\? Put it here#'
         - '#^Cognitive complexity for#'
-        - '#^Class cognitive complexity is#'
         - '#Do not use chained method calls. Put each on separated lines.#'
         - '#Do not inherit from abstract class, better use composition#'
         - '#Cannot access property [\$a-z_]+ on ((array\|)?object)#'
         - '#^Call to an undefined method CodeIgniter\\Database\\BaseBuilder#'
         - '#^Call to an undefined method CodeIgniter\\Database\\ConnectionInterface#'
-        - '#Access to an undefined property CodeIgniter\\Database\\BaseBuilder::\$pager#'
         - '#Function \"preg_.*\(\)\" cannot be used/left in the code#'
         - '#Function "property_exists\(\)" cannot be used/left in the code#'
         - '#Instead of "instanceof/is_a\(\)" use ReflectionProvider service or "\(new ObjectType\(<desired_type\>\)\)\-\>isSuperTypeOf\(<element_type\>\)" for static reflection to work#'
@@ -33,6 +30,4 @@ parameters:
             message: '#Function "function_exists\(\)" cannot be used/left in the code#'
             paths:
                 - app/Helpers
-                - app/Libraries/ActivityPub/Helpers
-                - app/Libraries/Analytics/Helpers
                 - app/Libraries/ViewComponents/Helpers
diff --git a/tailwind.config.js b/tailwind.config.js
index 4b4b984edd939611f4e8d17b7c654d9a988bb19c..922fe549fc498e93714c7d5f9f97b85121680137 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -5,6 +5,7 @@ module.exports = {
   purge: [
     "./app/Views/**/*.php",
     "./app/View/Components/**/*.php",
+    "./modules/**/Views/**/*.php",
     "./app/Helpers/*.php",
     "./app/Resources/**/*.ts",
   ],