Completely re-worked how backend:create command works
This commit is contained in:
41
FAQ.md
41
FAQ.md
@@ -239,42 +239,55 @@ The schema is simple, it's a list of users in the following format:
|
||||
version: "1.5"
|
||||
map:
|
||||
# 1st user...
|
||||
- my_plex_server:
|
||||
- my_plex_server: # your main user backend name
|
||||
name: "mike_jones"
|
||||
options: { } # optional key.
|
||||
my_jellyfin_server:
|
||||
my_jellyfin_server: # your main user backend name
|
||||
name: "jones_mike"
|
||||
my_emby_server:
|
||||
my_emby_server: # your main user backend name
|
||||
name: "mikeJones"
|
||||
replace_with: "mike_jones" # optional action, to replace the username with the new one.
|
||||
# 2nd user...
|
||||
- my_emby_server:
|
||||
- my_emby_server: # your main user backend name
|
||||
name: "jiji_jones"
|
||||
options: { } # optional key.
|
||||
my_plex_server:
|
||||
my_plex_server: # your main user backend name
|
||||
name: "jones_jiji"
|
||||
my_jellyfin_server:
|
||||
name: "jijiJones"
|
||||
my_jellyfin_server: # your main user backend name
|
||||
name: "jiji.jones"
|
||||
replace_with: "jiji_jones" # optional action, to replace the username with the new one.
|
||||
#.... more users
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> As we enforce specific naming convention for backend names and usernames they must follow the following format
|
||||
> `^[a-z_0-9]+$` which means, lowercase letters, numbers and `_` are allowed. No spaces, uppercase letters or special
|
||||
> characters are allowed. you can use the `replace_with` key to replace the username with the new one. if it's not
|
||||
> complying with the naming convention, or you want to force specific display name.
|
||||
If you create a map for a user, it SHOULD include all the backends you want to sync the user data with. while th matcher
|
||||
might automatically detect the other backends even if not included in the map, it best to manually set them in group to
|
||||
prevent any issues that might arise. Each list item is a user, and each user has a list of backends. Each backend
|
||||
|
||||
> [!NOTE]
|
||||
> the backend names `my_plex_server`, `my_jellyfin_server`, `my_emby_server` are the names you have chosen for
|
||||
> your> backends.
|
||||
> your backends.
|
||||
>
|
||||
> The `name` field is whatever the backend is reporting the username as.
|
||||
> The `name` field must match the name after normalization, so if you have a backend with the name `Mike Jones` as
|
||||
> username, the `name:` in the `mapper.yaml` file should be `mike_jones` as the `backend:create` command will normalize
|
||||
> the name before passing it to the mapper which the mapper will convert it to `mike_jones`.
|
||||
|
||||
## Important
|
||||
|
||||
We enforce strict naming convention for backend names and usernames, So they must follow the following format
|
||||
`^[a-z_0-9]+$` which means, lowercase letters, numbers and `_` are allowed. No spaces, uppercase letters or special
|
||||
characters or name entirely made of digits are allowed. If the username is not complying with the naming convention, we
|
||||
forcibly normalize it to make it comply with the naming convention.
|
||||
|
||||
If you want another name, you can use `replace_with` key to replace the username with the new one. However, the name
|
||||
also must comply with the naming convention.
|
||||
|
||||
This yaml file helps map your users usernames in the different backends, so the tool can sync the correct user data. If
|
||||
you added or updated mapping, you should delete `users` directory and generate new data. by running the `backend:create`
|
||||
command as described in the previous section.
|
||||
|
||||
You can run the `backend:create` command with `-v --dry-run` to see what it will do and if you need to create a mapping
|
||||
file or not.
|
||||
|
||||
----
|
||||
|
||||
# How do i migrate invited friends i.e. (external user) data from from plex to emby/jellyfin?
|
||||
|
||||
13
NEWS.md
13
NEWS.md
@@ -1,5 +1,18 @@
|
||||
# Old Updates
|
||||
|
||||
### 2025-02-11
|
||||
|
||||
We recently have added support to generate accesstoken for external `Plex` users, i.e. `not home users`. so the
|
||||
`backends:create` command now supports generating the needed config files for external users. Beware the support for
|
||||
this is still in early stages, and might not work as expected. report any issues you might face.
|
||||
|
||||
### 2025-02-05
|
||||
|
||||
We have added initial support to browse the WebUI as sub user, it's still in early stages, only few Endpoints support
|
||||
it.
|
||||
We have also added support to webhooks to allow sub users, you simply have to add new hooks using `user@backend`. Please
|
||||
take look at [this FAQ](FAQ.md#how-to-add-webhooks) to learn how to use it for sub users.
|
||||
|
||||
### 2025-02-02
|
||||
|
||||
We are happy to announce that we have merged in direct support for multi-user in `state:import` and `state:export`
|
||||
|
||||
33
README.md
33
README.md
@@ -9,9 +9,27 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
|
||||
|
||||
## Updates
|
||||
|
||||
## 2025-04-06
|
||||
|
||||
We have recently re-worked how the `backend:create` command works, and we no longer generate random name for invalid
|
||||
backends names or usernames. We do a normalization step to make sure the name is valid. This should help with the
|
||||
confusion of having random names. This means if you re-run the `backend:create` you most likely will get a different
|
||||
name then before. So, we suggest to re-run the command with `--re-create` flag. This flag will delete the current
|
||||
sub-users, and regenerate updated config files.
|
||||
|
||||
We have also added new guard for the command, so if you already generated your sub-users, re-running the command will
|
||||
show you a warning message and exit without doing anything. to run the command again either you need to use
|
||||
`--re-create`or `--run` flag. The `--run` flag will run the command without deleting the current sub-users.
|
||||
|
||||
We have also updated `mapper.yaml` file format to v1.5, please take moment to read the
|
||||
related the [FAQ](FAQ.md#whats-the-schema-for-the-mapperyaml-file)
|
||||
about it. The new format has added support for renaming the usernames, and the new spec is more versatile which should
|
||||
help us if we need to update something or add new features in the future.
|
||||
|
||||
### 2025-03-13
|
||||
|
||||
We have recently added support for plex webhooks via tautulli which you can use if you don't have PlexPass. This should help
|
||||
We have recently added support for plex webhooks via tautulli which you can use if you don't have PlexPass. This should
|
||||
help
|
||||
close the gap with other media servers.
|
||||
|
||||
### 2025-02-19
|
||||
@@ -27,19 +45,6 @@ with seconds as value, the minimum value is `180` seconds. `0` seconds means it'
|
||||
We are still not keen on this feature, and it might be removed in future releases if we aren't able to deal with the
|
||||
issues we are facing.
|
||||
|
||||
### 2025-02-11
|
||||
|
||||
We recently have added support to generate accesstoken for external `Plex` users, i.e. `not home users`. so the
|
||||
`backends:create` command now supports generating the needed config files for external users. Beware the support for
|
||||
this is still in early stages, and might not work as expected. report any issues you might face.
|
||||
|
||||
### 2025-02-05
|
||||
|
||||
We have added initial support to browse the WebUI as sub user, it's still in early stages, only few Endpoints support
|
||||
it.
|
||||
We have also added support to webhooks to allow sub users, you simply have to add new hooks using `user@backend`. Please
|
||||
take look at [this FAQ](FAQ.md#how-to-add-webhooks) to learn how to use it for sub users.
|
||||
|
||||
---
|
||||
Refer to [NEWS](NEWS.md) for old updates.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="field is-grouped">
|
||||
<p class="control" v-if="backends && backends.length>0">
|
||||
<button class="button is-purple" v-tooltip.bottom="'Create sub users backends.'"
|
||||
@click="navigateTo(makeConsoleCommand('backend:create -v', true))"
|
||||
@click="navigateTo(makeConsoleCommand('backend:create -B -v', true))"
|
||||
:disabled="'main' !== api_user">
|
||||
<span class="icon"><i class="fas fa-users"></i></span>
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace App\Commands\Backend;
|
||||
use App\Backends\Common\ClientInterface as iClient;
|
||||
use App\Backends\Plex\PlexClient;
|
||||
use App\Command;
|
||||
use App\Commands\State\BackupCommand;
|
||||
use App\Commands\System\TasksCommand;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\ConfigFile;
|
||||
@@ -42,8 +44,22 @@ class CreateUsersCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not commit any changes.')
|
||||
->addOption(
|
||||
're-create',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Delete current users configuration files and re-create them.'
|
||||
)
|
||||
->addOption('regenerate-tokens', 'g', InputOption::VALUE_NONE, 'Generate new tokens for PLEX users.')
|
||||
->addOption('--dry-run', null, InputOption::VALUE_NONE, 'Do not commit any changes.')
|
||||
->addOption('run', null, InputOption::VALUE_NONE, 'Allow creating the users even if data already exists.')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not commit any changes.')
|
||||
->addOption(
|
||||
'generate-backup',
|
||||
'B',
|
||||
InputOption::VALUE_NONE,
|
||||
'Generate initial backups for the remote user data.'
|
||||
)
|
||||
->addOption(
|
||||
'update',
|
||||
'u',
|
||||
@@ -74,25 +90,28 @@ class CreateUsersCommand extends Command
|
||||
Mapping is done automatically based on the username, however, if your users have different usernames
|
||||
on each backend, you can create <value>{path}/config/mapper.yaml</value> file with the following format:
|
||||
|
||||
- my_plex_server:
|
||||
version: "1.5"
|
||||
map:
|
||||
# first user
|
||||
-
|
||||
my_plex_server:
|
||||
name: "mike_jones"
|
||||
options: { }
|
||||
my_jellyfin_server:
|
||||
name: "jones_mike"
|
||||
options: { }
|
||||
my_emby_server:
|
||||
name: "mikeJones"
|
||||
options: { }
|
||||
replace_with: "mike_jones"
|
||||
# second user
|
||||
- my_emby_server:
|
||||
-
|
||||
my_emby_server:
|
||||
name: "jiji_jones"
|
||||
options: { }
|
||||
my_plex_server:
|
||||
name: "jones_jiji"
|
||||
options: { }
|
||||
my_jellyfin_server:
|
||||
name: "jijiJones"
|
||||
options: { }
|
||||
replace_with: "jiji_jones"
|
||||
|
||||
<question># How to regenerate tokens?</question>
|
||||
|
||||
@@ -116,47 +135,31 @@ class CreateUsersCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the command.
|
||||
*
|
||||
* @param iInput $input The input interface.
|
||||
* @param iOutput $output The output interface.
|
||||
*
|
||||
* @return int The exit code. 0 for success, 1 for failure.
|
||||
*/
|
||||
protected function runCommand(iInput $input, iOutput $output): int
|
||||
private function purgeUsersConfig(string $path, bool $dryRun): void
|
||||
{
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->logger->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
|
||||
if (false === is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: Deleting users directory '{path}' contents.", [
|
||||
'path' => $path
|
||||
]);
|
||||
|
||||
deletePath(path: $path, logger: $this->logger, dryRun: $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current user backends.
|
||||
*
|
||||
* @return array The list of backends.
|
||||
*/
|
||||
private function loadBackends(): array
|
||||
{
|
||||
$backends = [];
|
||||
$supported = Config::get('supported', []);
|
||||
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
|
||||
$configFile->setLogger($this->logger);
|
||||
|
||||
$mapFile = Config::get('mapper_file');
|
||||
$mapping = [];
|
||||
|
||||
if (file_exists($mapFile) && filesize($mapFile) > 10) {
|
||||
$map = ConfigFile::open(Config::get('mapper_file'), 'yaml');
|
||||
$mapping = $map->get('map', $map->getAll());
|
||||
if (!empty($mapping)) {
|
||||
if (false === $map->has('version') || false === $map->has('map')) {
|
||||
$this->logger->warning(
|
||||
"SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec for better compatibility and features, check the FAQ.md for the updated format.",
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info("SYSTEM: Mapper file found, using it to map users.", [
|
||||
'map' => arrayToString($mapping)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$backends = [];
|
||||
|
||||
foreach ($configFile->getAll() as $backendName => $backend) {
|
||||
$type = strtolower(ag($backend, 'type', 'unknown'));
|
||||
|
||||
@@ -182,21 +185,62 @@ class CreateUsersCommand extends Command
|
||||
$backends[$backendName] = $backend;
|
||||
}
|
||||
|
||||
if (empty($backends)) {
|
||||
$this->logger->error('SYSTEM: No valid backends were found.');
|
||||
return self::FAILURE;
|
||||
return $backends;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: Getting users list from '{backends}'.", [
|
||||
'backends' => join(', ', array_keys($backends))
|
||||
/**
|
||||
* Load user mappings from the mapper file.
|
||||
*
|
||||
* @return array The list of user mappings. or empty array.
|
||||
*/
|
||||
private function loadMappings(): array
|
||||
{
|
||||
$mapFile = Config::get('mapper_file');
|
||||
|
||||
if (false === file_exists($mapFile) || filesize($mapFile) < 10) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = ConfigFile::open(Config::get('mapper_file'), 'yaml');
|
||||
|
||||
$mapping = $map->get('map', $map->getAll());
|
||||
|
||||
if (empty($mapping)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (false === $map->has('version')) {
|
||||
$this->logger->warning("SYSTEM: Starting with mapper.yaml v1.5, the version key is required.");
|
||||
}
|
||||
|
||||
if (false === $map->has('map')) {
|
||||
$this->logger->warning("SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec.");
|
||||
}
|
||||
|
||||
$this->logger->info("SYSTEM: Mapper file found, using it to map users.", [
|
||||
'map' => arrayToString($mapping)
|
||||
]);
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backends users.
|
||||
*
|
||||
* @param array $backends The list of backends.
|
||||
* @param array $map The user mappings.
|
||||
*
|
||||
* @return array The list of backends users.
|
||||
*/
|
||||
private function get_backends_users(array $backends, array &$map): array
|
||||
{
|
||||
$users = [];
|
||||
|
||||
foreach ($backends as $backend) {
|
||||
/** @var iClient $client */
|
||||
$client = ag($backend, 'class');
|
||||
assert($backend instanceof iClient);
|
||||
|
||||
$this->logger->info("SYSTEM: Getting users from '{backend}'.", [
|
||||
'backend' => $client->getContext()->backendName
|
||||
]);
|
||||
@@ -206,41 +250,41 @@ class CreateUsersCommand extends Command
|
||||
/** @var array $info */
|
||||
$info = $backend;
|
||||
|
||||
$user = $this->map_actions($user, ag($backend, 'name'), $mapping);
|
||||
$backedName = ag($backend, 'name');
|
||||
|
||||
$_name = (string)ag($user, 'name');
|
||||
// -- this was source of lots of bugs and confusion for users,
|
||||
// -- we decided to normalize the user-names early in the process.
|
||||
$user['name'] = normalizeName((string)$user['name']);
|
||||
|
||||
if (false === isValidName($_name)) {
|
||||
$rename = substr(md5($_name), 0, 8);
|
||||
// -- run map actions.
|
||||
$this->map_actions($backedName, $user, $map);
|
||||
|
||||
// -- If normalization fails, ignore the user.
|
||||
if (false === isValidName($user['name'])) {
|
||||
$this->logger->error(
|
||||
message: "SYSTEM: Renaming invalid user name '{backend}: {name}' to '{backend}: {renamed}'. username must be in [a-z_0-9] format.",
|
||||
context: [
|
||||
'name' => $_name,
|
||||
'backend' => ag($backend, 'name'),
|
||||
'renamed' => $rename
|
||||
]
|
||||
message: "SYSTEM: Invalid user name '{backend}: {name}'. User names must be in [a-z_0-9] format. Skipping user.",
|
||||
context: ['name' => $user['name'], 'backend' => $backedName]
|
||||
);
|
||||
$user = ag_set($user, 'name', $rename);
|
||||
continue;
|
||||
}
|
||||
|
||||
// -- user here refers to user_id not the name.
|
||||
$info['user'] = ag($user, 'id', ag($info, 'user'));
|
||||
|
||||
// -- The display name is used to create user directory.
|
||||
$info['displayName'] = ag($user, 'name');
|
||||
$info['displayName'] = $user['name'];
|
||||
|
||||
$info['backendName'] = strtolower(r("{backend}_{user}", [
|
||||
'backend' => ag($backend, 'name'),
|
||||
'user' => ag($user, 'name'),
|
||||
$info['backendName'] = normalizeName(r("{backend}_{user}", [
|
||||
'backend' => $backedName,
|
||||
'user' => $user['name']
|
||||
]));
|
||||
|
||||
if (false === isValidName($info['backendName'])) {
|
||||
$rename = substr(md5($info['backendName']), 0, 8);
|
||||
$this->logger->error(
|
||||
message: "SYSTEM: Renaming invalid backend name '{name}'. backend name must be in [a-z_0-9], renaming to '{renamed}'",
|
||||
context: ['name' => $info['backendName'], 'renamed' => $rename]
|
||||
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping the associated users.",
|
||||
context: ['name' => $info['backendName']]
|
||||
);
|
||||
$info['backendName'] = $rename;
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = ag_delete($info, 'options.' . Options::PLEX_USER_PIN);
|
||||
@@ -249,7 +293,7 @@ class CreateUsersCommand extends Command
|
||||
'options.' . Options::ALT_ID => ag($backend, 'user')
|
||||
]);
|
||||
|
||||
// -- of course, Plex has to be special.
|
||||
// -- Of course, Plex has to be special.
|
||||
if (PlexClient::CLIENT_NAME === ucfirst(ag($backend, 'type'))) {
|
||||
$info = ag_sets($info, [
|
||||
'token' => 'reuse_or_generate_token',
|
||||
@@ -265,8 +309,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$user['backend'] = ag($backend, 'name');
|
||||
$user['client_data'] = $info;
|
||||
$user = ag_sets($user, ['backend' => $backedName, 'client_data' => $info]);
|
||||
$users[] = $user;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -293,50 +336,67 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$users = $this->generate_users_list($users);
|
||||
|
||||
if (count($users) < 1) {
|
||||
$this->logger->warning('No users were found.');
|
||||
return self::FAILURE;
|
||||
return $users;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: User matching results {results}.", [
|
||||
'results' => arrayToString($this->usersList($users)),
|
||||
]);
|
||||
/**
|
||||
* Create user configuration files.
|
||||
*
|
||||
* @param iInput $input The input interface.
|
||||
* @param array $users The list of users to create.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function create_user(iInput $input, array $users): void
|
||||
{
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$updateUsers = (bool)$input->getOption('update');
|
||||
$regenerateTokens = (bool)$input->getOption('regenerate-tokens');
|
||||
$generateBackups = (bool)$input->getOption('generate-backup');
|
||||
$isReCreate = (bool)$input->getOption('re-create');
|
||||
|
||||
foreach ($users as $user) {
|
||||
$userName = strtolower(ag($user, 'name', 'unknown'));
|
||||
// -- User subdirectory name.
|
||||
$userName = normalizeName(ag($user, 'name', 'unknown'));
|
||||
|
||||
if (false === isValidName($userName)) {
|
||||
$rename = substr(md5($userName), 0, 8);
|
||||
$this->logger->error(
|
||||
message: "SYSTEM: Renaming invalid username '{user}'. Username must be in [a-z_0-9], renaming to '{renamed}'",
|
||||
context: ['user' => $userName, 'renamed' => $rename]
|
||||
message: "SYSTEM: Invalid username '{user}'. User names must be in [a-z_0-9] format. skipping user.",
|
||||
context: ['user' => $userName]
|
||||
);
|
||||
$userName = $rename;
|
||||
continue;
|
||||
}
|
||||
|
||||
$subUserPath = r(fixPath(Config::get('path') . '/users/{user}'), ['user' => $userName]);
|
||||
|
||||
if (false === is_dir($subUserPath)) {
|
||||
$this->logger->info("SYSTEM: Creating '{user}' directory '{path}'.", [
|
||||
$this->logger->info(
|
||||
false === is_dir(
|
||||
$subUserPath
|
||||
) ? "SYSTEM: Creating '{user}' directory '{path}'." : "SYSTEM: '{user}' directory '{path}' already exists.",
|
||||
[
|
||||
'user' => $userName,
|
||||
'path' => $subUserPath
|
||||
]);
|
||||
'path' => $subUserPath,
|
||||
]
|
||||
);
|
||||
|
||||
if (false === $dryRun && false === mkdir($subUserPath, 0755, true)) {
|
||||
if (false === $dryRun && false === is_dir($subUserPath) && false === mkdir($subUserPath, 0755, true)) {
|
||||
$this->logger->error("SYSTEM: Failed to create '{user}' directory '{path}'.", [
|
||||
'user' => $userName,
|
||||
'path' => $subUserPath
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$config_file = "{$subUserPath}/servers.yaml";
|
||||
$this->logger->notice("SYSTEM: Creating '{user}' configuration file '{file}'.", [
|
||||
$this->logger->notice(
|
||||
file_exists(
|
||||
$config_file
|
||||
) ? "SYSTEM: '{user}' configuration file '{file}' already exists." : "SYSTEM: Creating '{user}' configuration file '{file}'.",
|
||||
[
|
||||
'user' => $userName,
|
||||
'file' => $config_file
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
$perUser = ConfigFile::open(
|
||||
file: $dryRun ? "php://memory" : $config_file,
|
||||
@@ -347,17 +407,16 @@ class CreateUsersCommand extends Command
|
||||
);
|
||||
|
||||
$perUser->setLogger($this->logger);
|
||||
$regenerateTokens = $input->getOption('regenerate-tokens');
|
||||
|
||||
foreach (ag($user, 'backends', []) as $backend) {
|
||||
$name = ag($backend, 'client_data.backendName');
|
||||
|
||||
if (false === isValidName($name)) {
|
||||
$rename = substr(md5($name), 0, 8);
|
||||
$this->logger->error(
|
||||
message: "SYSTEM: Renaming invalid backend name '{name}'. backend name must be in [a-z_0-9], renaming to '{renamed}'",
|
||||
context: ['name' => $name, 'renamed' => $rename]
|
||||
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping backend.",
|
||||
context: ['name' => $name]
|
||||
);
|
||||
$name = $rename;
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientData = ag_delete(ag($backend, 'client_data'), 'class');
|
||||
@@ -371,7 +430,7 @@ class CreateUsersCommand extends Command
|
||||
} else {
|
||||
$clientData = ag_delete($clientData, ['token', 'import.lastSync', 'export.lastSync']);
|
||||
$clientData = array_replace_recursive($perUser->get($name), $clientData);
|
||||
if ($input->getOption('update')) {
|
||||
if (true === $updateUsers) {
|
||||
$update = [
|
||||
'url' => ag($backend, 'client_data.url'),
|
||||
'options.ALT_NAME' => ag($backend, 'client_data.name'),
|
||||
@@ -464,22 +523,119 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$dbFile = r($subUserPath . "/{user}.db", ['user' => 'user']);
|
||||
if (false === file_exists($dbFile)) {
|
||||
$this->logger->notice("SYSTEM: Creating '{user}' database '{db}'.", [
|
||||
$dbFile = $subUserPath . "/user.db";
|
||||
$this->logger->notice(
|
||||
file_exists(
|
||||
$dbFile
|
||||
) ? "SYSTEM: '{user}' database file '{db}' already exists." : "SYSTEM: Creating '{user}' database file '{db}'.",
|
||||
[
|
||||
'user' => $userName,
|
||||
'db' => $dbFile
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
if (false === $dryRun) {
|
||||
if (false === file_exists($dbFile)) {
|
||||
perUserDb($userName);
|
||||
}
|
||||
|
||||
$perUser->persist();
|
||||
}
|
||||
|
||||
if (true === $generateBackups && false === $isReCreate) {
|
||||
$this->logger->notice("SYSTEM: Queuing event to backup '{user}' remote watch state.", [
|
||||
'user' => $userName
|
||||
]);
|
||||
|
||||
if (false === $dryRun) {
|
||||
queueEvent(TasksCommand::CNAME, [
|
||||
'command' => BackupCommand::ROUTE,
|
||||
'args' => ['-v', '-u', $userName, '--file', '{user}.{backend}.{date}.initial_backup.json'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $dryRun) {
|
||||
$perUser->persist();
|
||||
/**
|
||||
* Executes the command.
|
||||
*
|
||||
* @param iInput $input The input interface.
|
||||
* @param iOutput $output The output interface.
|
||||
*
|
||||
* @return int The exit code. 0 for success, 1 for failure.
|
||||
*/
|
||||
protected function runCommand(iInput $input, iOutput $output): int
|
||||
{
|
||||
if (true === ($dryRun = $input->getOption('dry-run'))) {
|
||||
$this->logger->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
|
||||
}
|
||||
|
||||
$usersPath = Config::get('path') . '/users';
|
||||
$hasConfig = is_dir($usersPath) && count(glob($usersPath . '/*/*.yaml')) > 0;
|
||||
|
||||
if ($hasConfig && (false === $input->getOption('run') && false === $input->getOption('re-create'))) {
|
||||
$output->writeln(
|
||||
<<<Text
|
||||
<error>ERROR:</error> Users configuration already exists.
|
||||
|
||||
If you want to re-create the users configuration, run the same command with [<flag>-r, --re-create</flag>] flag,
|
||||
This will do the following:
|
||||
|
||||
1. Delete the current sub-users configuration and data.
|
||||
2. Re-create the sub-users configuration.
|
||||
|
||||
Otherwise, you can use the [<flag>--run</flag>] to keep current configuration and update it with the new
|
||||
users.
|
||||
<value>
|
||||
Beware, we have recently changed how we do matching, most likely if you run without re-creating the configuration.
|
||||
it will result in double users for same user or more.
|
||||
</value>
|
||||
<notice>
|
||||
We suggest to re-create the configuration. If you generated your users before date 2025-04-06.
|
||||
</notice>
|
||||
Text
|
||||
);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (true === $input->getOption('re-create')) {
|
||||
$this->purgeUsersConfig($usersPath, $dryRun);
|
||||
}
|
||||
|
||||
$backends = $this->loadBackends();
|
||||
|
||||
if (empty($backends)) {
|
||||
$this->logger->error('SYSTEM: No valid backends were found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$mapping = $this->loadMappings();
|
||||
|
||||
$this->logger->notice("SYSTEM: Getting users list from '{backends}'.", [
|
||||
'backends' => join(', ', array_keys($backends))
|
||||
]);
|
||||
|
||||
$backendsUser = $this->get_backends_users($backends, $mapping);
|
||||
|
||||
if (count($backendsUser) < 1) {
|
||||
$this->logger->error('SYSTEM: No Backend users were found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$users = $this->generate_users_list($backendsUser, $mapping);
|
||||
|
||||
if (count($users) < 1) {
|
||||
$this->logger->warning("We weren't able to match any users across backends.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: User matching results {results}.", [
|
||||
'results' => arrayToString($this->usersList($users)),
|
||||
]);
|
||||
|
||||
$this->create_user(input: $input, users: $users);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -487,10 +643,11 @@ class CreateUsersCommand extends Command
|
||||
* Generate a list of users that are matched across all backends.
|
||||
*
|
||||
* @param array $users The list of users from all backends.
|
||||
* @param array{string: array{string: string, options: array}} $map The map of users to match.
|
||||
*
|
||||
* @return array{name: string, backends: array<string, array<string, mixed>>}[] The list of matched users.
|
||||
*/
|
||||
private function generate_users_list(array $users): array
|
||||
private function generate_users_list(array $users, array $map = []): array
|
||||
{
|
||||
$allBackends = [];
|
||||
foreach ($users as $u) {
|
||||
@@ -501,20 +658,19 @@ class CreateUsersCommand extends Command
|
||||
|
||||
// Build a lookup: $usersBy[backend][lowercased_name] = userObject
|
||||
$usersBy = [];
|
||||
$usersList = [];
|
||||
foreach ($users as $user) {
|
||||
$backend = $user['backend'];
|
||||
$nameLower = (string)strtolower((string)$user['name']);
|
||||
$nameLower = strtolower($user['name']);
|
||||
if (ag($user, 'id') === ag($user, 'client_data.options.' . Options::ALT_ID)) {
|
||||
$this->logger->debug('Skipping main user "{backend}: {name}".', [
|
||||
'name' => $user['name'],
|
||||
'backend' => $user['backend'],
|
||||
]);
|
||||
$this->logger->debug('Skipping main user "{name}".', ['name' => $user['name']]);
|
||||
continue;
|
||||
}
|
||||
if (!isset($usersBy[$backend])) {
|
||||
$usersBy[$backend] = [];
|
||||
}
|
||||
$usersBy[$backend][(string)$nameLower] = $user;
|
||||
$usersBy[$backend][$nameLower] = $user;
|
||||
$usersList[$backend][] = $nameLower;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
@@ -547,7 +703,7 @@ class CreateUsersCommand extends Command
|
||||
$names = [];
|
||||
foreach ($allBackends as $b) {
|
||||
if (isset($backendDict[$b])) {
|
||||
$names[] = (string)$backendDict[$b]['name'];
|
||||
$names[] = $backendDict[$b]['name'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,8 +740,6 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$finalName = (string)$finalName;
|
||||
|
||||
// Build final row: "name" + sub-array "backends"
|
||||
$row = [
|
||||
'name' => strtolower($finalName),
|
||||
@@ -611,13 +765,90 @@ class CreateUsersCommand extends Command
|
||||
|
||||
// For each user in this backend
|
||||
foreach ($usersBy[$backend] as $nameLower => $userObj) {
|
||||
$nameLower = (string)$nameLower;
|
||||
|
||||
// Skip if already used
|
||||
if ($alreadyUsed($backend, $nameLower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map-based matching first
|
||||
$matchedMapEntry = null;
|
||||
foreach ($map as $mapRow) {
|
||||
if (ag($mapRow, "{$backend}.name") === $nameLower) {
|
||||
$this->logger->notice("Mapper: Found map entry for '{backend}: {user}'", [
|
||||
'backend' => $backend,
|
||||
'user' => $nameLower,
|
||||
'map' => $mapRow,
|
||||
]);
|
||||
$matchedMapEntry = $mapRow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedMapEntry) {
|
||||
// Build mapMatch from the map row.
|
||||
$mapMatch = [$backend => $userObj];
|
||||
|
||||
// Gather all the other backends from the map
|
||||
foreach ($allBackends as $otherBackend) {
|
||||
if ($otherBackend === $backend) {
|
||||
continue;
|
||||
}
|
||||
if (isset($matchedMapEntry[$otherBackend]['name'])) {
|
||||
$mappedNameLower = strtolower($matchedMapEntry[$otherBackend]['name']);
|
||||
if (isset($usersBy[$otherBackend][$mappedNameLower])) {
|
||||
$mapMatch[$otherBackend] = $usersBy[$otherBackend][$mappedNameLower];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we matched ≥ 2 backends, unify them
|
||||
if (count($mapMatch) >= 2) {
|
||||
// --- MERGE map-based "options" into client_data => options, if any ---
|
||||
foreach ($mapMatch as $b => &$matchedUser) {
|
||||
// If the map entry has an 'options' array for this backend,
|
||||
// merge it into $matchedUser['client_data']['options'].
|
||||
if (isset($matchedMapEntry[$b]['options']) && is_array($matchedMapEntry[$b]['options'])) {
|
||||
$mapOptions = $matchedMapEntry[$b]['options'];
|
||||
|
||||
// Ensure $matchedUser['client_data'] is an array
|
||||
if (!isset($matchedUser['client_data']) || !is_array($matchedUser['client_data'])) {
|
||||
$matchedUser['client_data'] = [];
|
||||
}
|
||||
|
||||
// Ensure $matchedUser['client_data']['options'] is an array
|
||||
if (!isset($matchedUser['client_data']['options']) || !is_array(
|
||||
$matchedUser['client_data']['options']
|
||||
)) {
|
||||
$matchedUser['client_data']['options'] = [];
|
||||
}
|
||||
|
||||
// Merge the map's options
|
||||
$matchedUser['client_data']['options'] = array_replace_recursive(
|
||||
$matchedUser['client_data']['options'],
|
||||
$mapOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
unset($matchedUser); // break reference from the loop
|
||||
|
||||
// Build final row
|
||||
$results[] = $buildUnifiedRow($mapMatch);
|
||||
|
||||
// Mark & remove from $usersBy
|
||||
foreach ($mapMatch as $b => $mu) {
|
||||
$nm = strtolower($mu['name']);
|
||||
$used[] = [$b, $nm];
|
||||
unset($usersBy[$b][$nm]);
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
$this->logger->error("No partial fallback match via map for '{backend}: {user}'", [
|
||||
'backend' => $userObj['backend'],
|
||||
'user' => $userObj['name'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct-name matching if map fails
|
||||
$directMatch = [$backend => $userObj];
|
||||
foreach ($allBackends as $otherBackend) {
|
||||
@@ -645,9 +876,11 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
// If neither map nor direct matched for ≥2
|
||||
$this->logger->error("Direct mapping failed for '{backend}: {user}' no match found.", [
|
||||
$this->logger->error("No other users were found that match '{backend}: {user}'.", [
|
||||
'backend' => $userObj['backend'],
|
||||
'user' => $userObj['name']
|
||||
'user' => $userObj['name'],
|
||||
'map' => arrayToString($map),
|
||||
'list' => arrayToString($usersList),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -684,8 +917,6 @@ class CreateUsersCommand extends Command
|
||||
* @param string $backend The backend name.
|
||||
* @param array $mapping the mapper file data.
|
||||
*
|
||||
* @return array the modified user data if any.
|
||||
*
|
||||
* - my_plex_server:
|
||||
* name: "mike_jones"
|
||||
* options: { }
|
||||
@@ -698,67 +929,63 @@ class CreateUsersCommand extends Command
|
||||
* options: { }
|
||||
* ```
|
||||
*/
|
||||
private function map_actions(array $user, string $backend, array $mapping): array
|
||||
private function map_actions(string $backend, array &$user, array &$mapping): void
|
||||
{
|
||||
if (null === ($username = ag($user, 'name'))) {
|
||||
$this->logger->debug("SYSTEM: No username found for '{backend}' backend.", [
|
||||
$this->logger->error("MAPPER: No username was given from one user of '{backend}' backend.", [
|
||||
'backend' => $backend
|
||||
]);
|
||||
return $user;
|
||||
return;
|
||||
}
|
||||
|
||||
// -- check if backend has mapping
|
||||
$hasMapping = array_filter($mapping, fn($map) => array_key_exists($backend, $map));
|
||||
if (empty($hasMapping)) {
|
||||
$this->logger->debug("No mapping found for '{backend}' backend.", [
|
||||
if (count($hasMapping) < 1) {
|
||||
$this->logger->info("MAPPER: No mapping exists for '{backend}' backend.", [
|
||||
'backend' => $backend
|
||||
]);
|
||||
return $user;
|
||||
return;
|
||||
}
|
||||
|
||||
$found = false;
|
||||
$user_map = [];
|
||||
|
||||
foreach ($mapping as $map) {
|
||||
$map_backend = array_keys($map)[0];
|
||||
|
||||
foreach ($mapping as &$map) {
|
||||
foreach ($map as $map_backend => &$loop_map) {
|
||||
if ($backend !== $map_backend) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ag($map, "{$backend}.name") !== $username) {
|
||||
if (ag($loop_map, "name") !== $username) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found = true;
|
||||
$user_map = ag($map, $backend, []);
|
||||
break;
|
||||
$user_map = &$loop_map;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
$this->logger->debug("No mapping found for '{backend}: {username}'.", [
|
||||
$this->logger->debug("MAPPER: No map exists for '{backend}: {username}'.", [
|
||||
'backend' => $backend,
|
||||
'username' => $username
|
||||
]);
|
||||
return $user;
|
||||
return;
|
||||
}
|
||||
|
||||
// -- replace_with action.
|
||||
if (null !== ($newUsername = ag($user_map, 'replace_with'))) {
|
||||
if (!is_string($newUsername) || false === isValidName($newUsername)) {
|
||||
if (false === is_string($newUsername) || false === isValidName($newUsername)) {
|
||||
$this->logger->error(
|
||||
message: "SYSTEM: Mapper failed to rename '{backend}: {username}' to '{backend}: {new_username}' name must be in [a-z_0-9] format.",
|
||||
message: "MAPPER: Failed to replace '{backend}: {username}' with '{backend}: {new_username}' name must be in [a-z_0-9] format.",
|
||||
context: [
|
||||
'backend' => $backend,
|
||||
'username' => $username,
|
||||
'new_username' => $newUsername
|
||||
]
|
||||
);
|
||||
return $user;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->notice(
|
||||
message: "SYSTEM: Mapper is renaming '{backend}: {username}' to '{backend}: {new_username}'.",
|
||||
message: "MAPPER: Renaming '{backend}: {username}' to '{backend}: {new_username}'.",
|
||||
context: [
|
||||
'backend' => $backend,
|
||||
'username' => $username,
|
||||
@@ -767,8 +994,7 @@ class CreateUsersCommand extends Command
|
||||
);
|
||||
|
||||
$user['name'] = $newUsername;
|
||||
$user_map['name'] = $newUsername;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,6 @@ final class TasksCommand extends Command
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (self::CNAME === $eventName) {
|
||||
$event->addLog(r("Task: Run '{name}'.", ['name' => $eventName]));
|
||||
$exitCode = $this->run_command(
|
||||
|
||||
@@ -858,20 +858,23 @@ if (!function_exists('getAppVersion')) {
|
||||
if (is_dir($gitDir)) {
|
||||
try {
|
||||
// Get the current branch name.
|
||||
$cmdBranch = 'git --git-dir={cwd} rev-parse --abbrev-ref HEAD';
|
||||
$cmdBranch = 'git --git-path
|
||||
={cwd} rev-parse --abbrev-ref HEAD';
|
||||
$procBranch = Process::fromShellCommandline(r($cmdBranch, ['cwd' => escapeshellarg($gitDir)]));
|
||||
$procBranch->run();
|
||||
$branch = $procBranch->isSuccessful() ? trim($procBranch->getOutput()) : 'unknown';
|
||||
|
||||
// Get the short commit hash.
|
||||
$cmdCommit = 'git --git-dir={cwd} rev-parse --short HEAD';
|
||||
$cmdCommit = 'git --git-path
|
||||
={cwd} rev-parse --short HEAD';
|
||||
$procCommit = Process::fromShellCommandline(r($cmdCommit, ['cwd' => escapeshellarg($gitDir)]));
|
||||
$procCommit->run();
|
||||
$commit = $procCommit->isSuccessful() ? trim($procCommit->getOutput()) : 'unknown';
|
||||
|
||||
// Get the commit date (from HEAD) in YYYYMMDD format.
|
||||
// This uses "git show" with a custom date format.
|
||||
$cmdDate = 'git --git-dir={cwd} show -s --format=%cd --date=format:%Y%m%d HEAD';
|
||||
$cmdDate = 'git --git-path
|
||||
={cwd} show -s --format=%cd --date=format:%Y%m%d HEAD';
|
||||
$procDate = Process::fromShellCommandline(r($cmdDate, ['cwd' => escapeshellarg($gitDir)]));
|
||||
$procDate->run();
|
||||
$commitDate = $procDate->isSuccessful() ? trim($procDate->getOutput()) : date('Ymd');
|
||||
@@ -1165,13 +1168,16 @@ if (false === function_exists('generateRoutes')) {
|
||||
try {
|
||||
$dirs = [__DIR__ . '/../Commands'];
|
||||
foreach (array_keys(Config::get('supported', [])) as $backend) {
|
||||
$dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]);
|
||||
$path
|
||||
= r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]);
|
||||
|
||||
if (!file_exists($dir)) {
|
||||
if (!file_exists(
|
||||
$path
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dirs[] = $dir;
|
||||
$dirs[] = $path;
|
||||
}
|
||||
foreach (AttributesScanner::scan($dirs, allowNonInvokable: true)->for(Cli::class) as $item) {
|
||||
$routes_cli[] = [
|
||||
@@ -2592,17 +2598,33 @@ if (!function_exists('getUsersContext')) {
|
||||
}
|
||||
|
||||
if (false === is_readable($usersDir)) {
|
||||
throw new RuntimeException(r("Unable to read '{dir}' directory.", ['dir' => $usersDir]));
|
||||
throw new RuntimeException(
|
||||
r(
|
||||
"Unable to read '{path
|
||||
}' directory.",
|
||||
[
|
||||
'path
|
||||
' => $usersDir
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach (new DirectoryIterator(Config::get('path') . '/users') as $dir) {
|
||||
if ($dir->isDot() || false === $dir->isDir()) {
|
||||
foreach (new DirectoryIterator(Config::get('path') . '/users') as $path
|
||||
) {
|
||||
if ($path
|
||||
->isDot() || false === $path
|
||||
->isDir()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = perUserConfig($dir->getBasename());
|
||||
$config = perUserConfig(
|
||||
$path
|
||||
->getBasename()
|
||||
);
|
||||
|
||||
$userName = $dir->getBasename();
|
||||
$userName = $path
|
||||
->getBasename();
|
||||
$perUserCache = perUserCacheAdapter($userName);
|
||||
$db = perUserDb($userName);
|
||||
if (count($dbOpts) > 0) {
|
||||
@@ -2653,3 +2675,69 @@ if (!function_exists('getUserContext')) {
|
||||
return $users[$user];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('deletePath')) {
|
||||
/**
|
||||
* Delete the contents of given path.
|
||||
*
|
||||
* @param string $path The path to delete.
|
||||
* @param iLogger|null $logger The logger instance.
|
||||
* @param bool $dryRun (Optional) Whether to perform a dry run.
|
||||
*
|
||||
* @return bool Whether the operation was successful.
|
||||
*/
|
||||
function deletePath(string $path, iLogger|null $logger = null, bool $dryRun = false): bool
|
||||
{
|
||||
if (false === is_dir($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
iterator: new RecursiveDirectoryIterator(
|
||||
directory: $path,
|
||||
flags: RecursiveDirectoryIterator::SKIP_DOTS
|
||||
),
|
||||
mode: RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
if (null !== $logger) {
|
||||
$context = [
|
||||
'path' => $item->getPathname(),
|
||||
'type' => $item->isDir() ? 'directory' : 'file'
|
||||
];
|
||||
$logger->info("Removing {type} '{path}'.", $context);
|
||||
}
|
||||
|
||||
if (true === $dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === $item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('normalizeName')) {
|
||||
/**
|
||||
* Normalize the name to be in [a-z_0-9] format.
|
||||
*
|
||||
* @param string $name The name to normalize.
|
||||
*
|
||||
* @return string The normalized name.
|
||||
*/
|
||||
function normalizeName(string $name): string
|
||||
{
|
||||
$name = strtolower($name);
|
||||
$name = preg_replace('/[^a-z0-9_]/', '_', $name);
|
||||
return trim($name);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user