diff --git a/FAQ.md b/FAQ.md index 65e11fec..3ea7fd1a 100644 --- a/FAQ.md +++ b/FAQ.md @@ -211,23 +211,32 @@ database state back to the selected backend. ### Is there support for Multi-user setup? -There are minimal support for multi-user setup via `state:sync` command. There are some requirements to get it working -correctly. The tools will try to match the users based on the name, and fallback on the `mapper.yaml` file if it's -provided. The tool will try to sync the users data between the backends. +We are on early stage of supporting multi-user setups, initially few operations are supported. To get started, first you +need to create your own main user backends using admin token for Plex and api key for Jellyfin/Emby. -#### Things that will get synced +Once your own main user is added, make sure to turn on the `import` and `export` for all backends, as the sub users are +initial configuration is based on your own main user configuration. Once your own user is working, turn on the `import` +and `export` tasks in the Tasks page. -* Play status, i.e. watched/unwatched. -* Watch progress. +Now, to create the sub users configurations, you need to run `backend:create` command, which can be done via +`WebUI > Backends > Purple button (users) icon` or via CLI by running the following command: -#### Requirements to get the command working +```bash +$ docker exec -ti watchstate console backend:create -v +``` -* All backends need to have admin level access, this is needed to inquiry about the users and generate the required - access tokens. -* That means for plex, it needs the admin token, to find it - check [plex article about it](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). -* For jellyfin/emby you need to use the API key, not the user password. You can generate api keys via Dashboard > - Advanced > API Keys. +Once the configuration is created, You can start using the multi-user functionality. Start by enabling the `sync` task +which is responsible for syncing the users play state and watch progress between the backends. + +To enable the task, you can do it via `WebUI > Tasks` page or via CLI by running the following command: + +```bash +$ docker exec -ti watchstate console system:env -k WS_CRON_SYNC -e true +``` + +If your users usernames are different between the backends, you can use the `mapper.yaml` file to map the users between +the backends. For more information about the `mapper.yaml` file, please refer to +the [mapper.yaml](#whats-the-schema-for-the-mapperyaml-file) section. #### Whats the schema for the `mapper.yaml` file? @@ -243,7 +252,7 @@ The schema is simple, it's a list of users in the following format: my_emby_server: name: "mikeJones" options: { } - +# 2nd user... - my_emby_server: name: "jiji_jones" options: { } @@ -253,15 +262,12 @@ The schema is simple, it's a list of users in the following format: my_jellyfin_server: name: "jijiJones" options: { } +#.... more users ``` -This yaml file helps map your users accounts in the different backends, so the tool can sync the correct user data. - -Then simply run `state:sync -v` it will generate the required tokens and match users data between the backends. -then sync the difference. By default, the task is scheduled to run every 3 hour, you can change the schedule by -altering the `WS_CRON_SYNC_AT` environment variable via `ENV` page or `system:env` command. - -To have the task run automatically, you need to enable the task via the `WebUI > Tasks` page or `system:env` command. +This yaml file helps map your users username 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. ---- diff --git a/NEWS.md b/NEWS.md index 9a5692ba..8b8a2134 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,18 @@ # Old Updates +### 2025-01-18 + +Due to popular demand, we finally have added the ability to sync all users data, however, it's limited to only +play state, no progress syncing implemented at this stage. This feature still in alpha expect bugs and issues. + +However our local tests shows that it's working as expected, but we need more testing to be sure. Please report any +issues you encounter. To enable this feature, you will see new task in the `Tasks` page called `Sync`. + +This task will sync all your users play state, However you need to have the backends added with admin token for plex and +API key for jellyfin and emby. Enable the task and let it run, it will sync all users play state. + +Please read the FAQ entry about it at [this link](FAQ.md#is-there-support-for-multi-user-setup). + ### 2024-12-30 We have removed the old environment variables `WS_CRON_PROGRESS` and `WS_CRON_PUSH` in favor of the new ones diff --git a/README.md b/README.md index 9ec03414..52818c7c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,21 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. ## Updates +### 2025-02-01 + +Breaking changes as of version 20250201~, in earlier versions, if you want to sync multi-user play state, you only had +to run `state:sync` command, However, due to us extending support for more operation to support multi-user data, we +needed a way to generate per user config instead of relying on `state:sync`, thus we have introduced a new command +called `backends:create`, the purpose of this command is to generate the needed config files for each user. + +This change allow us to support more operations in the future. + +We also have minor breaking change in per user db name, before it was named `user_name.db`, now it's named `user.db` +this change shouldn't effect you as we have backward compatibility in place to rename the old db to the new name. + +for more information about multi-user, Please read the FAQ entry about it +at [this link](FAQ.md#is-there-support-for-multi-user-setup). + ### 2025-01-24 We are excited to share that multi-user sync is now fully supported! Our first goal was to make sure the feature worked, @@ -16,19 +31,6 @@ and since releasing it, we’ve worked hard to improve it based on feedback and as expected and are happy to invite you to start using it. To learn more and get started, please check out the FAQ entry here: [this link](FAQ.md#is-there-support-for-multi-user-setup). -### 2025-01-18 - -Due to popular demand, we finally have added the ability to sync all users data, however, it's limited to only -play state, no progress syncing implemented at this stage. This feature still in alpha expect bugs and issues. - -However our local tests shows that it's working as expected, but we need more testing to be sure. Please report any -issues you encounter. To enable this feature, you will see new task in the `Tasks` page called `Sync`. - -This task will sync all your users play state, However you need to have the backends added with admin token for plex and -API key for jellyfin and emby. Enable the task and let it run, it will sync all users play state. - -Please read the FAQ entry about it at [this link](FAQ.md#is-there-support-for-multi-user-setup). - --- Refer to [NEWS](NEWS.md) for old updates. diff --git a/config/config.php b/config/config.php index 71a34aef..e3d866d1 100644 --- a/config/config.php +++ b/config/config.php @@ -276,7 +276,7 @@ return (function () { SyncCommand::TASK_NAME => [ 'command' => SyncCommand::ROUTE, 'name' => SyncCommand::TASK_NAME, - 'info' => 'Sync ALL users play state. Read the FAQ.', + 'info' => 'Sync sub users play states.', 'enabled' => (bool)env('WS_CRON_SYNC', false), 'timer' => $checkTaskTimer((string)env('WS_CRON_SYNC_AT', '9 */3 * * *'), '9 */3 * * *'), 'args' => env('WS_CRON_SYNC_ARGS', '-v'), diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index 2aac5bf7..3223202f 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -140,6 +140,16 @@ body { border: var(--bulma-control-border-width) solid rgba(56, 56, 56, 0.38); } +.button.is-purple { + background-color: #5f00d1; + border-color: transparent; + color: #fff; +} + +.has-text-purple { + color: #5f00d1; +} + @media screen and (min-width: 769px), print { .field.is-grouped-tablet { display: flex; diff --git a/frontend/pages/backends/index.vue b/frontend/pages/backends/index.vue index 56774e7a..16772d22 100644 --- a/frontend/pages/backends/index.vue +++ b/frontend/pages/backends/index.vue @@ -8,6 +8,12 @@
+ +
+ * return [
+ * 'name' => 'something',
+ * 'backends' => [
+ * 'backend1' => userObj,
+ * 'backend2' => userObj,
+ * ...,
+ * ]
+ * ]
+ *
+ */
+ $buildUnifiedRow = function (array $backendDict) use ($allBackends): array {
+ // Collect the names in the order of $allBackends for tie-breaking.
+ $names = [];
+ foreach ($allBackends as $b) {
+ if (isset($backendDict[$b])) {
+ $names[] = $backendDict[$b]['name'];
+ }
+ }
+
+ // Tally frequencies
+ $freq = [];
+ foreach ($names as $n) {
+ if (!isset($freq[$n])) {
+ $freq[$n] = 0;
+ }
+ $freq[$n]++;
+ }
+
+ // Decide a final 'name'
+ if (empty($freq)) {
+ $finalName = 'unknown';
+ } else {
+ $max = max($freq);
+ $candidates = array_keys(array_filter($freq, fn($count) => $count === $max));
+
+ if (1 === count($candidates)) {
+ $finalName = $candidates[0];
+ } else {
+ // Tie => pick the first from $names that’s in $candidates
+ $finalName = null;
+ foreach ($names as $n) {
+ if (in_array($n, $candidates, true)) {
+ $finalName = $n;
+ break;
+ }
+ }
+ if (!$finalName) {
+ $finalName = 'unknown';
+ }
+ }
+ }
+
+ // Build final row: "name" + sub-array "backends"
+ $row = [
+ 'name' => $finalName,
+ 'backends' => [],
+ ];
+
+ // Fill 'backends'
+ foreach ($allBackends as $b) {
+ if (isset($backendDict[$b])) {
+ $row['backends'][$b] = $backendDict[$b];
+ }
+ }
+
+ return $row;
+ };
+
+ // Main logic: For each backend and each user in that backend, unify them if we find a match in ≥2 backends.
+ // We do map-based matching first, then direct-name matching.
+ foreach ($allBackends as $backend) {
+ if (!isset($usersBy[$backend])) {
+ continue;
+ }
+
+ // For each user in this backend
+ foreach ($usersBy[$backend] as $nameLower => $userObj) {
+ // Skip if already used
+ if ($alreadyUsed($backend, $nameLower)) {
+ continue;
+ }
+
+ // Map-based matching first
+ $matchedMapEntry = null;
+ foreach ($map as $mapRow) {
+ if (isset($mapRow[$backend]['name']) && strtolower($mapRow[$backend]['name']) === $nameLower) {
+ $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) {
+ if ($otherBackend === $backend) {
+ continue;
+ }
+ // Same name => direct match
+ if (isset($usersBy[$otherBackend][$nameLower])) {
+ $directMatch[$otherBackend] = $usersBy[$otherBackend][$nameLower];
+ }
+ }
+
+ // If direct matched ≥ 2 backends, unify
+ if (count($directMatch) >= 2) {
+ // No map "options" to merge here
+ $results[] = $buildUnifiedRow($directMatch);
+
+ // Mark & remove them from $usersBy
+ foreach ($directMatch as $b => $matchedUser) {
+ $nm = strtolower($matchedUser['name']);
+ $used[] = [$b, $nm];
+ unset($usersBy[$b][$nm]);
+ }
+ continue;
+ }
+
+ // If neither map nor direct matched for ≥2
+ $this->logger->error("Cannot match user '{backend}: {user}' in any map row or direct match.", [
+ 'backend' => $userObj['backend'],
+ 'user' => $userObj['name']
+ ]);
+ }
+ }
+
+ return $results;
+ }
+
+ private function usersList(array $list): array
+ {
+ $chunks = [];
+
+ foreach ($list as $row) {
+ $name = $row['name'] ?? 'unknown';
+
+ $pairs = [];
+ if (!empty($row['backends']) && is_array($row['backends'])) {
+ foreach ($row['backends'] as $backendName => $backendData) {
+ if (isset($backendData['name'])) {
+ $pairs[] = r("{name}@{backend}", ['backend' => $backendName, 'name' => $backendData['name']]);
+ }
+ }
+ }
+
+ $chunks[] = r("{name}: {pairs}", ['name' => $name, 'pairs' => implode(', ', $pairs)]);
+ }
+
+ return $chunks;
+ }
+
+}
diff --git a/src/Commands/State/BackupCommand.php b/src/Commands/State/BackupCommand.php
index 7607878f..0aa61e15 100644
--- a/src/Commands/State/BackupCommand.php
+++ b/src/Commands/State/BackupCommand.php
@@ -14,7 +14,6 @@ use App\Libs\Mappers\ExtendedImportInterface as iEImport;
use App\Libs\Mappers\Import\DirectMapper;
use App\Libs\Options;
use App\Libs\Stream;
-use DirectoryIterator;
use Psr\Http\Message\StreamInterface as iStream;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Component\Console\Input\InputInterface as iInput;
@@ -154,73 +153,6 @@ class BackupCommand extends Command
return $this->single(fn(): int => $this->process($input), $output);
}
- private function getBackends(iInput $input): array
- {
- $configs = [
- 'main' => [
- 'config' => ConfigFile::open(Config::get('backends_file'), 'yaml'),
- 'mapper' => $this->mapper,
- 'cache' => null,
- ]
- ];
-
- if (true === $input->getOption('only-main-user')) {
- return $configs;
- }
-
- $usersDir = Config::get('path') . '/users';
-
- if (false === is_dir($usersDir)) {
- return $configs;
- }
-
- if (!is_readable($usersDir)) {
- $this->logger->error("SYSTEM: Unable to read '{dir}' directory.", ['dir' => $usersDir]);
- return $configs;
- }
-
- $mainUserIds = array_map(fn($backend) => ag($backend, 'user'), ag($configs, 'main.config')->getAll());
-
- foreach (new DirectoryIterator(Config::get('path') . '/users') as $dir) {
- if ($dir->isDot() || false === $dir->isDir()) {
- continue;
- }
-
- $config = perUserConfig($dir->getBasename());
- $subUserIds = array_map(fn($backend) => ag($backend, 'user'), $config->getAll());
-
- foreach ($mainUserIds as $mainId) {
- if (false === in_array($mainId, $subUserIds)) {
- continue;
- }
-
- $this->logger->debug("SYSTEM: Skipping '{user}' backends as it's same as main user.", [
- 'user' => $dir->getBasename(),
- 'main' => $mainUserIds,
- 'sub' => $subUserIds,
- ]);
- continue 2;
- }
-
- $userName = $dir->getBasename();
- $perUserCache = perUserCacheAdapter($userName);
-
- $configs[$userName] = [
- 'config' => $config,
- 'mapper' => $this->mapper->withDB(perUserDb($userName))
- ->withCache($perUserCache)
- ->withLogger($this->logger)
- ->withOptions(
- array_replace_recursive($this->mapper->getOptions(), [Options::ALT_NAME => $userName])
- )
- ->loadData(),
- 'cache' => $perUserCache,
- ];
- }
-
- return $configs;
- }
-
/**
* Execute the command.
*
@@ -245,8 +177,13 @@ class BackupCommand extends Command
$this->mapper->setOptions(options: $mapperOpts);
}
+ $opts = [];
+ if (true === (bool)$input->getOption('only-main-user')) {
+ $opts = ['main_user_only' => true];
+ }
+
$this->logger->notice("Using WatchState version - '{version}'.", ['version' => getAppVersion()]);
- foreach ($this->getBackends($input) as $user => $opt) {
+ foreach ($this->getUserData($this->mapper, $this->logger, $opts) as $user => $opt) {
try {
$this->process_backup($input, $user, $opt);
} finally {
@@ -449,7 +386,7 @@ class BackupCommand extends Command
gc_collect_cycles();
}
- foreach ($list as $backend) {
+ foreach ($list as $b => $backend) {
if (null === ($backend['fp'] ?? null)) {
continue;
}
@@ -462,7 +399,8 @@ class BackupCommand extends Command
if (false === $noCompression) {
$file = $backend['fp']->getMetadata('uri');
- $this->logger->notice("SYSTEM: Compressing '{user}@{file}'.", [
+ $this->logger->notice("SYSTEM: Compressing '{user}@{name}' backup file '{file}'.", [
+ 'name' => $b,
'user' => $user,
'file' => $file
]);
diff --git a/src/Commands/State/SyncCommand.php b/src/Commands/State/SyncCommand.php
index 0a3c58a8..0e1db593 100644
--- a/src/Commands/State/SyncCommand.php
+++ b/src/Commands/State/SyncCommand.php
@@ -6,7 +6,6 @@ namespace App\Commands\State;
use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface as iClient;
-use App\Backends\Plex\PlexClient;
use App\Command;
use App\Libs\Attributes\DI\Inject;
use App\Libs\Attributes\Route\Cli;
@@ -45,8 +44,6 @@ class SyncCommand extends Command
public const string TASK_NAME = 'sync';
- private array $mapping = [];
-
/**
* Class Constructor.
*
@@ -92,59 +89,7 @@ class SyncCommand extends Command
InputOption::VALUE_NONE,
'Mapper option. Always update the locally stored metadata from backend.'
)
- ->addOption('regenerate-tokens', 'g', InputOption::VALUE_NONE, 'Generate new tokens for all users.')
- ->addOption('include-main-user', null, InputOption::VALUE_NONE, 'Include main user in sync.')
- ->setHelp(
- r(
- <<
- * return [
- * 'name' => 'something',
- * 'backends' => [
- * 'backend1' => userObj,
- * 'backend2' => userObj,
- * ...,
- * ]
- * ]
- *
- */
- $buildUnifiedRow = function (array $backendDict) use ($allBackends): array {
- // Collect the names in the order of $allBackends for tie-breaking.
- $names = [];
- foreach ($allBackends as $b) {
- if (isset($backendDict[$b])) {
- $names[] = $backendDict[$b]['name'];
- }
- }
-
- // Tally frequencies
- $freq = [];
- foreach ($names as $n) {
- if (!isset($freq[$n])) {
- $freq[$n] = 0;
- }
- $freq[$n]++;
- }
-
- // Decide a final 'name'
- if (empty($freq)) {
- $finalName = 'unknown';
- } else {
- $max = max($freq);
- $candidates = array_keys(array_filter($freq, fn($count) => $count === $max));
-
- if (1 === count($candidates)) {
- $finalName = $candidates[0];
- } else {
- // Tie => pick the first from $names that’s in $candidates
- $finalName = null;
- foreach ($names as $n) {
- if (in_array($n, $candidates, true)) {
- $finalName = $n;
- break;
- }
- }
- if (!$finalName) {
- $finalName = 'unknown';
- }
- }
- }
-
- // Build final row: "name" + sub-array "backends"
- $row = [
- 'name' => $finalName,
- 'backends' => [],
- ];
-
- // Fill 'backends'
- foreach ($allBackends as $b) {
- if (isset($backendDict[$b])) {
- $row['backends'][$b] = $backendDict[$b];
- }
- }
-
- return $row;
- };
-
- // Main logic: For each backend and each user in that backend, unify them if we find a match in ≥2 backends.
- // We do map-based matching first, then direct-name matching.
- foreach ($allBackends as $backend) {
- if (!isset($usersBy[$backend])) {
- continue;
- }
-
- // For each user in this backend
- foreach ($usersBy[$backend] as $nameLower => $userObj) {
- // Skip if already used
- if ($alreadyUsed($backend, $nameLower)) {
- continue;
- }
-
- // Map-based matching first
- $matchedMapEntry = null;
- foreach ($map as $mapRow) {
- if (isset($mapRow[$backend]['name']) && strtolower($mapRow[$backend]['name']) === $nameLower) {
- $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) {
- if ($otherBackend === $backend) {
- continue;
- }
- // Same name => direct match
- if (isset($usersBy[$otherBackend][$nameLower])) {
- $directMatch[$otherBackend] = $usersBy[$otherBackend][$nameLower];
- }
- }
-
- // If direct matched ≥ 2 backends, unify
- if (count($directMatch) >= 2) {
- // No map "options" to merge here
- $results[] = $buildUnifiedRow($directMatch);
-
- // Mark & remove them from $usersBy
- foreach ($directMatch as $b => $matchedUser) {
- $nm = strtolower($matchedUser['name']);
- $used[] = [$b, $nm];
- unset($usersBy[$b][$nm]);
- }
- continue;
- }
-
- // If neither map nor direct matched for ≥2
- $this->logger->error("Cannot match user '{backend}: {user}' in any map row or direct match.", [
- 'backend' => $userObj['backend'],
- 'user' => $userObj['name']
- ]);
- }
- }
-
- return $results;
- }
-
- private function usersList(array $list): array
- {
- $chunks = [];
-
- foreach ($list as $row) {
- $name = $row['name'] ?? 'unknown';
-
- $pairs = [];
- if (!empty($row['backends']) && is_array($row['backends'])) {
- foreach ($row['backends'] as $backendName => $backendData) {
- if (isset($backendData['name'])) {
- $pairs[] = r("{name}@{backend}", ['backend' => $backendName, 'name' => $backendData['name']]);
- }
- }
- }
-
- $chunks[] = r("{name}: {pairs}", ['name' => $name, 'pairs' => implode(', ', $pairs)]);
- }
-
- return $chunks;
+ return array_any($haystack, fn($item) => str_starts_with($item, $needle));
}
}
diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php
index 54088e0c..16eff0da 100644
--- a/src/Libs/Mappers/Import/MemoryMapper.php
+++ b/src/Libs/Mappers/Import/MemoryMapper.php
@@ -392,20 +392,22 @@ class MemoryMapper implements ExtendedImportInterface
Message::increment("{$entity->via}.{$entity->type}.ignored_not_played_since_last_sync");
if ($entity->isWatched() !== $this->objects[$pointer]->isWatched()) {
- $this->logger->notice(
- "{mapper}: [O] '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.",
- [
- 'mapper' => afterLast(self::class, '\\'),
- 'id' => $this->objects[$pointer]->id,
- 'backend' => $entity->via,
- 'remote_date' => makeDate($entity->updated),
- 'local_date' => makeDate($opts['after']),
- 'state' => $entity->isWatched() ? 'played' : 'unplayed',
- 'local_state' => $this->objects[$pointer]->isWatched() ? 'played' : 'unplayed',
- 'title' => $entity->getName(),
- ]
- );
- return $this;
+ if ($this->inTraceMode()) {
+ $this->logger->debug(
+ "{mapper}: [O] '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.",
+ [
+ 'mapper' => afterLast(self::class, '\\'),
+ 'id' => $this->objects[$pointer]->id,
+ 'backend' => $entity->via,
+ 'remote_date' => makeDate($entity->updated),
+ 'local_date' => makeDate($opts['after']),
+ 'state' => $entity->isWatched() ? 'played' : 'unplayed',
+ 'local_state' => $this->objects[$pointer]->isWatched() ? 'played' : 'unplayed',
+ 'title' => $entity->getName(),
+ ]
+ );
+ }
+ return $this->handleTainted($pointer, $cloned, $entity, $opts);
}
if ($this->inTraceMode()) {
diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php
index f965bc7f..b7ff9123 100644
--- a/src/Libs/helpers.php
+++ b/src/Libs/helpers.php
@@ -2214,7 +2214,18 @@ if (!function_exists('perUserDb')) {
}
}
- $dbFile = fixPath(r("{path}/{user}.db", ['path' => $path, 'user' => $user]));
+ $dbFile = fixPath(r("{path}/user.db", ['path' => $path]));
+ $oldDb = fixPath(r("{path}/{user}.db", ['path' => $path, 'user' => $user]));
+ if (true === file_exists($oldDb)) {
+ if (false === file_exists($dbFile)) {
+ rename($oldDb, $dbFile);
+ clearstatcache(true, $oldDb);
+ clearstatcache(true, $dbFile);
+ } else {
+ unlink($oldDb);
+ }
+ }
+
$inTestMode = true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE);
$dsn = r('sqlite:{src}', ['src' => $inTestMode ? ':memory:' : $dbFile]);
@@ -2261,7 +2272,7 @@ if (!function_exists('perUserConfig')) {
}
}
- return ConfigFile::open(fixPath(r("{path}/servers.yaml", ['path' => $path])), 'yaml');
+ return ConfigFile::open(fixPath(r("{path}/servers.yaml", ['path' => $path])), 'yaml', autoCreate: true);
}
}
@@ -2283,36 +2294,7 @@ if (!function_exists('perUserCacheAdapter')) {
}
try {
- $cacheUrl = Config::get('cache.url');
-
- if (empty($cacheUrl)) {
- throw new RuntimeException('No cache server was set.');
- }
-
- if (!extension_loaded('redis')) {
- throw new RuntimeException('Redis extension is not loaded.');
- }
-
- $uri = new Uri($cacheUrl);
- $params = [];
-
- if (!empty($uri->getQuery())) {
- parse_str($uri->getQuery(), $params);
- }
-
- $redis = new Redis();
-
- $redis->connect($uri->getHost(), $uri->getPort() ?? 6379);
-
- if (null !== ag($params, 'password')) {
- $redis->auth(ag($params, 'password'));
- }
-
- if (null !== ag($params, 'db')) {
- $redis->select((int)ag($params, 'db'));
- }
-
- $backend = new RedisAdapter(redis: $redis, namespace: $ns);
+ $backend = new RedisAdapter(redis: Container::get(Redis::class), namespace: $ns);
} catch (Throwable) {
// -- in case of error, fallback to file system cache.
$path = fixPath(r("{path}/users/{user}/cache", ['path' => Config::get('path'), 'user' => $user]));
@@ -2419,3 +2401,4 @@ if (!function_exists('readFileFromArchive')) {
return [Stream::make($stream, 'r'), $zip];
}
}
+