diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f2b6726..e80f35e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -192,56 +192,80 @@ jobs: create_release: needs: publish_docker_images runs-on: ubuntu-latest - # Example condition: run on default branch or if triggered manually with create_release == 'true' + # Example condition: run only if this is the default branch, or + # if triggered manually with input "create_release". if: (endsWith(github.ref, github.event.repository.default_branch) && success()) || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true') - steps: - name: Check out code - id: checkout_code uses: actions/checkout@v4 with: - fetch-depth: 0 # so we can do full 'git log' and see all tags + fetch-depth: 0 # so we can see all tags + full history + + - name: Determine current branch + id: branch + run: | + # github.ref_name should be "master", "main", or your branch name + echo "BRANCH_NAME=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT - - name: Fetch the most recent tag - id: last_tag + - name: Fetch the two latest tags for this branch + id: last_two_tags run: | - # Make sure we have all tags from the remote git fetch --tags - # Get the latest tag by commit date (the "newest" tag you pushed). - # If the repo has no tags, fall back to "no-tags-found" - LATEST_TAG=$(git describe --tags --abbrev=0 "$(git rev-list --tags --max-count=1)" 2>/dev/null || echo "no-tags-found") + BRANCH_NAME="${{ steps.branch.outputs.BRANCH_NAME }}" + echo "Current branch: $BRANCH_NAME" + + # List tags matching "branchname-*" and sort by *creation date* descending + # Then pick the top 2 + LATEST_TAGS=$(git tag --list "${BRANCH_NAME}-*" --sort=-creatordate | head -n 2) + TAG_COUNT=$(echo "$LATEST_TAGS" | wc -l) + + echo "Found tags:" + echo "$LATEST_TAGS" + + if [ "$TAG_COUNT" -lt 2 ]; then + echo "Not enough tags found (need at least 2) to compare commits." + echo "NOT_ENOUGH_TAGS=true" >> "$GITHUB_OUTPUT" + exit 0 + fi - echo "Latest tag found: $LATEST_TAG" - echo "LAST_TAG=$LATEST_TAG" >> "$GITHUB_OUTPUT" + # The first line is the newest tag + TAG_NEWEST=$(echo "$LATEST_TAGS" | sed -n '1p') + # The second line is the previous newest + TAG_PREVIOUS=$(echo "$LATEST_TAGS" | sed -n '2p') - - name: Get commits since last tag + echo "Newest tag: $TAG_NEWEST" + echo "Previous tag: $TAG_PREVIOUS" + + # Expose them as outputs for next step + echo "NOT_ENOUGH_TAGS=false" >> "$GITHUB_OUTPUT" + echo "TAG_NEWEST=$TAG_NEWEST" >> "$GITHUB_OUTPUT" + echo "TAG_PREVIOUS=$TAG_PREVIOUS" >> "$GITHUB_OUTPUT" + + - name: Generate commit log for newest tag id: commits + if: steps.last_two_tags.outputs.NOT_ENOUGH_TAGS != 'true' run: | - # We'll compare the newest tag to HEAD to get a list of commits - LAST_TAG="${{ steps.last_tag.outputs.LAST_TAG }}" - - if [ "$LAST_TAG" = "no-tags-found" ]; then - echo "No previous tag found. Listing all commits from the beginning." - LOG=$(git log --pretty=format:"- %h %s by %an") - else - echo "Comparing commits from $LAST_TAG to HEAD" - LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"- %h %s by %an") - fi + TAG_NEWEST="${{ steps.last_two_tags.outputs.TAG_NEWEST }}" + TAG_PREVIOUS="${{ steps.last_two_tags.outputs.TAG_PREVIOUS }}" + + echo "Comparing commits between: $TAG_PREVIOUS..$TAG_NEWEST" + LOG=$(git log "$TAG_PREVIOUS".."$TAG_NEWEST" --pretty=format:"- %h %s by %an") echo "LOG<> "$GITHUB_ENV" echo "$LOG" >> "$GITHUB_ENV" echo "EOF" >> "$GITHUB_ENV" - - name: Create GitHub Release using last tag + - name: Create / Update GitHub Release for the newest tag + if: steps.last_two_tags.outputs.NOT_ENOUGH_TAGS != 'true' uses: softprops/action-gh-release@master with: - tag_name: ${{ steps.last_tag.outputs.LAST_TAG }} - name: "${{ steps.last_tag.outputs.LAST_TAG }}" + tag_name: ${{ steps.last_two_tags.outputs.TAG_NEWEST }} + name: "${{ steps.last_two_tags.outputs.TAG_NEWEST }}" body: ${{ env.LOG }} append_body: true generate_release_notes: true + make_latest: true draft: false prerelease: false - make_latest: true token: ${{ secrets.GITHUB_TOKEN }} diff --git a/config/directories.php b/config/directories.php index 6800cee0..886c54d6 100644 --- a/config/directories.php +++ b/config/directories.php @@ -6,6 +6,7 @@ '{path}/db/archive', '{path}/config', '{path}/backup', + '{path}/users', '{tmp_dir}/logs', '{tmp_dir}/cache', '{tmp_dir}/profiler', diff --git a/src/Backends/Common/Cache.php b/src/Backends/Common/Cache.php index 4ee25a30..f84ea3d4 100644 --- a/src/Backends/Common/Cache.php +++ b/src/Backends/Common/Cache.php @@ -43,6 +43,23 @@ public function __construct(private iLogger $logger, private iCache $cache) { } + /** + * Clone the object with the given logger and cache adapter. + * + * @param iLogger|null $logger The logger to use. If not provided, the current logger is used. + * @param iCache|null $adapter The cache adapter to use. If not provided, the current cache adapter is used. + * + * @return Cache return new instance of Cache class. + */ + public function with(iLogger|null $logger = null, iCache|null $adapter = null): self + { + $cloned = clone $this; + $cloned->logger = $logger ?? $this->logger; + $cloned->cache = $adapter ?? $this->cache; + + return $cloned; + } + /** * Clone the object with the data retrieved from the cache based on the key. * diff --git a/src/Commands/State/SyncCommand.php b/src/Commands/State/SyncCommand.php index 763df671..c0fd5b5c 100644 --- a/src/Commands/State/SyncCommand.php +++ b/src/Commands/State/SyncCommand.php @@ -4,15 +4,18 @@ namespace App\Commands\State; +use App\Backends\Common\Cache as BackendCache; use App\Backends\Common\ClientInterface as iClient; use App\Command; use App\Libs\Attributes\Route\Cli; use App\Libs\Config; use App\Libs\ConfigFile; +use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; use App\Libs\Extends\StreamLogHandler; use App\Libs\LogSuppressor; -use App\Libs\Mappers\Import\NullMapper; +use App\Libs\Mappers\ExtendedImportInterface as iEImport; +use App\Libs\Mappers\Import\MemoryMapper; use App\Libs\Message; use App\Libs\Options; use App\Libs\QueueRequests; @@ -45,12 +48,12 @@ class SyncCommand extends Command /** * Class Constructor. * - * @param NullMapper $mapper The instance of the DirectMapper class. + * @param MemoryMapper $mapper The instance of the DirectMapper class. * @param QueueRequests $queue The instance of the QueueRequests class. * @param iLogger $logger The instance of the iLogger class. */ public function __construct( - private readonly NullMapper $mapper, + private readonly MemoryMapper $mapper, private readonly QueueRequests $queue, private readonly iLogger $logger, private readonly LogSuppressor $suppressor, @@ -97,12 +100,14 @@ protected function configure(): void We need the admin token for plex to generate user tokens for each user, and we need the API keys for jellyfin/emby to get the user list and update their play state. - Known limitions + Known limitations - We have some known limitations, - * Cannot be used with plex users that have PIN enabled. - * Can Only sync played status. - * Cannot sync play progress. + We have some known limitations: + * Cannot be used with plex users that have PIN enabled. + * Can Only sync played status. + * Cannot sync play progress. + + Some or all of these limitations will be fixed in future releases. # How does this sync operation mode work? @@ -321,8 +326,13 @@ protected function process(iInput $input, iOutput $output): int ]); foreach (array_reverse($users) as $user) { + $userName = ag($user, 'name', 'Unknown'); + $perUserCache = perUserCacheAdapter($userName); + $perUserMapper = perUserMapper($this->mapper, $userName) + ->withCache($perUserCache) + ->withLogger($this->logger)->loadData(); + $this->queue->reset(); - $this->mapper->reset(); $list = []; $displayName = null; @@ -331,7 +341,9 @@ protected function process(iInput $input, iOutput $output): int $name = ag($backend, 'client_data.backendName'); $clientData = ag($backend, 'client_data'); $clientData['name'] = $name; - $clientData['class'] = makeBackend($clientData, $name)->setLogger($this->logger); + $clientData['class'] = makeBackend($clientData, $name, [ + BackendCache::class => Container::get(BackendCache::class)->with(adapter: $perUserCache) + ])->setLogger($this->logger); $list[$name] = $clientData; $displayName = ag($backend, 'client_data.displayName', '??'); } @@ -343,9 +355,12 @@ protected function process(iInput $input, iOutput $output): int 'started' => $start, ]); - $this->handleImport($displayName, $list); + assert($perUserMapper instanceof iEImport); + $this->handleImport($perUserMapper, $displayName, $list); - $changes = $this->mapper->computeChanges(array_keys($list)); + assert($perUserMapper instanceof MemoryMapper); + /** @var MemoryMapper $changes */ + $changes = $perUserMapper->computeChanges(array_keys($list)); foreach ($changes as $b => $changed) { $count = count($changed); @@ -392,7 +407,7 @@ protected function process(iInput $input, iOutput $output): int return self::SUCCESS; } - protected function handleImport(string $name, array $backends): void + protected function handleImport(iEImport $mapper, string $name, array $backends): void { /** @var array $queue */ $queue = []; @@ -400,7 +415,7 @@ protected function handleImport(string $name, array $backends): void foreach ($backends as $backend) { /** @var iClient $client */ $client = ag($backend, 'class'); - array_push($queue, ...$client->pull($this->mapper)); + array_push($queue, ...$client->pull($mapper)); } $start = makeDate(); diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php index c65818c3..64ee703a 100644 --- a/src/Libs/Database/DBLayer.php +++ b/src/Libs/Database/DBLayer.php @@ -24,7 +24,7 @@ final class DBLayer implements LoggerAwareInterface private int $count = 0; - private string $driver; + private string $driver = ''; private array $last = [ 'sql' => '', @@ -66,6 +66,19 @@ public function __construct(private readonly PDO $pdo, private array $options = $this->retry = ag($this->options, 'retry', self::LOCK_RETRY); } + /** + * Create a new instance with the given PDO object and options. + * + * @param PDO $pdo The PDO object. + * @param array|null $options The options to be passed to the new instance, or null to use the current options. + * + * @return self The new instance. + */ + public function withPDO(PDO $pdo, array|null $options = null): self + { + return new self($pdo, $options ?? $this->options); + } + /** * Execute a SQL statement and return the number of affected rows. * The execution will be wrapped into {@link DBLayer::wrap()} method. to handle database locks. diff --git a/src/Libs/Database/DatabaseInterface.php b/src/Libs/Database/DatabaseInterface.php index cb8a7aa4..efcb435d 100644 --- a/src/Libs/Database/DatabaseInterface.php +++ b/src/Libs/Database/DatabaseInterface.php @@ -8,7 +8,7 @@ use Closure; use DateTimeInterface; use PDOException; -use Psr\Log\LoggerInterface; +use Psr\Log\LoggerInterface as iLogger; interface DatabaseInterface { @@ -16,6 +16,25 @@ interface DatabaseInterface public const string MIGRATE_DOWN = 'down'; + /** + * Create new instance. + * @param iLogger|null $logger Logger to use, if null use default. + * @param DBLayer|null $db Database layer to use, if null use default. + * @param array|null $options PDO options. + * + * @return self Return new instance. + */ + public function with(iLogger|null $logger = null, DBLayer|null $db = null, array|null $options = null): self; + + /** + * Set options + * + * @param array $options PDO options + * + * @return self return new instance with options. + */ + public function withOptions(array $options): self; + /** * Set options * @@ -124,11 +143,11 @@ public function ensureIndex(array $opts = []): mixed; * Migrate data from old database schema to new one. * * @param string $version Version to migrate to. - * @param LoggerInterface|null $logger Logger to use. + * @param iLogger|null $logger Logger to use. * * @return mixed Return value depends on the driver. */ - public function migrateData(string $version, LoggerInterface|null $logger = null): mixed; + public function migrateData(string $version, iLogger|null $logger = null): mixed; /** * Is the database up to date with migrations? @@ -166,11 +185,11 @@ public function reset(): bool; /** * Inject Logger. * - * @param LoggerInterface $logger + * @param iLogger $logger * * @return $this */ - public function setLogger(LoggerInterface $logger): self; + public function setLogger(iLogger $logger): self; /** * Get DBLayer instance. diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php index 499c773b..c793f168 100644 --- a/src/Libs/Database/PDO/PDOAdapter.php +++ b/src/Libs/Database/PDO/PDOAdapter.php @@ -30,11 +30,6 @@ final class PDOAdapter implements iDB */ private bool $viaTransaction = false; - /** - * @var array Adapter options. - */ - private array $options = []; - /** * @var array Prepared statements. */ @@ -49,8 +44,21 @@ final class PDOAdapter implements iDB * @param iLogger $logger The logger object used for logging. * @param DBLayer $db The PDO object used for database connections. */ - public function __construct(private iLogger $logger, private readonly DBLayer $db) + public function __construct(private iLogger $logger, private readonly DBLayer $db, private array $options = []) + { + } + + public function with(iLogger|null $logger = null, DBLayer|null $db = null, array|null $options = null): self + { + if (null === $logger && null === $db && null === $options) { + return $this; + } + return new self($logger ?? $this->logger, $db ?? $this->db, $options ?? $this->options); + } + + public function withOptions(array $options): self { + return $this->with(options: $options); } /** diff --git a/src/Libs/Mappers/ExtendedImportInterface.php b/src/Libs/Mappers/ExtendedImportInterface.php new file mode 100644 index 00000000..a09ea3fd --- /dev/null +++ b/src/Libs/Mappers/ExtendedImportInterface.php @@ -0,0 +1,45 @@ +db = $db; + return $instance; + } + + /** + * @inheritdoc + */ + public function withCache(iCache $cache): self + { + $instance = clone $this; + $instance->cache = $cache; + return $instance; + } + + /** + * @inheritdoc + */ + public function withLogger(iLogger $logger): self + { + $instance = clone $this; + $instance->logger = $logger; + return $instance; + } + /** * @inheritdoc */ @@ -958,6 +988,14 @@ public function getChangedList(): array return $this->changed; } + /** + * @inheritdoc + */ + public function computeChanges(array $backends): array + { + return []; + } + /** * Adds pointers to the entity. * diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index ed567239..01569d6c 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -7,7 +7,7 @@ use App\Libs\Config; use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Entity\StateInterface as iState; -use App\Libs\Mappers\ImportInterface as iImport; +use App\Libs\Mappers\ExtendedImportInterface as iImport; use App\Libs\Message; use App\Libs\Options; use App\Listeners\ProcessProgressEvent; @@ -70,6 +70,36 @@ public function __construct(protected iLogger $logger, protected iDB $db, protec { } + /** + * @inheritdoc + */ + public function withDB(iDB $db): self + { + $instance = clone $this; + $instance->db = $db; + return $instance; + } + + /** + * @inheritdoc + */ + public function withCache(iCache $cache): self + { + $instance = clone $this; + $instance->cache = $cache; + return $instance; + } + + /** + * @inheritdoc + */ + public function withLogger(iLogger $logger): self + { + $instance = clone $this; + $instance->logger = $logger; + return $instance; + } + /** * @inheritdoc */ @@ -772,6 +802,29 @@ public function getChangedList(): array return $this->changed; } + /** + * @inheritdoc + */ + public function computeChanges(array $backends): array + { + $changes = []; + + foreach ($backends as $backend) { + $changes[$backend] = []; + } + + foreach ($this->objects as $entity) { + $state = $entity->isSynced($backends); + foreach ($state as $b => $value) { + if (false === $value) { + $changes[$b][] = $entity; + } + } + } + + return $changes; + } + /** * Add pointers to the pointer storage. * diff --git a/src/Libs/Mappers/Import/NullMapper.php b/src/Libs/Mappers/Import/NullMapper.php index 76a92da6..78249166 100644 --- a/src/Libs/Mappers/Import/NullMapper.php +++ b/src/Libs/Mappers/Import/NullMapper.php @@ -71,33 +71,6 @@ public function commit(): array ]; } - /** - * Compute the play state for each backend. - * - * @param array $backends List of backends to check. - * - * @return array List of changes for each backend. - */ - public function computeChanges(array $backends): array - { - $changes = []; - - foreach ($backends as $backend) { - $changes[$backend] = []; - } - - foreach ($this->objects as $entity) { - $state = $entity->isSynced($backends); - foreach ($state as $b => $value) { - if (false === $value) { - $changes[$b][] = $entity; - } - } - } - - return $changes; - } - public function __destruct() { // -- disabled autocommit. diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 27f47ffd..a94871e3 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -14,6 +14,7 @@ use App\Libs\Config; use App\Libs\ConfigFile; use App\Libs\Container; +use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; @@ -27,6 +28,7 @@ use App\Libs\Extends\ReflectionContainer; use App\Libs\Guid; use App\Libs\Initializer; +use App\Libs\Mappers\ExtendedImportInterface as iEImport; use App\Libs\Options; use App\Libs\Response; use App\Libs\Stream; @@ -46,7 +48,13 @@ use Psr\Http\Message\StreamInterface as iStream; use Psr\Http\Message\UriInterface as iUri; use Psr\Log\LoggerInterface as iLogger; +use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\CacheInterface as iCache; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\RedisAdapter; +use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Process\Process; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; @@ -648,11 +656,12 @@ function after(string $subject, string $search): string * * @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, options:array} $backend * @param string|null $name server name. + * @param array $options * * @return iClient backend client instance. * @throws InvalidArgumentException if configuration is wrong. */ - function makeBackend(array $backend, string|null $name = null): iClient + function makeBackend(array $backend, string|null $name = null, array $options = []): iClient { if (null === ($backendType = ag($backend, 'type'))) { throw new InvalidArgumentException('No backend type was set.'); @@ -676,7 +685,7 @@ function makeBackend(array $backend, string|null $name = null): iClient clientName: $backendType, backendName: $name ?? ag($backend, 'name', '??'), backendUrl: new Uri(ag($backend, 'url')), - cache: Container::get(BackendCache::class), + cache: $options[BackendCache::class] ?? Container::get(BackendCache::class), backendId: ag($backend, 'uuid', null), backendToken: ag($backend, 'token', null), backendUser: ag($backend, 'user', null), @@ -2171,3 +2180,111 @@ function timeIt(Closure $function, string $name, int $round = 6): string ]); } } + +if (!function_exists('perUserMapper')) { + /** + * User Import Mapper. + * + * @param iEImport $mapper The mapper instance. + * @param string $user The username. + * + * @return iEImport new mapper instance. + */ + function perUserMapper(iEImport $mapper, string $user): iEImport + { + $path = fixPath(r("{path}/users/{user}", ['path' => Config::get('path'), 'user' => $user])); + if (false === file_exists($path)) { + if (false === @mkdir($path, 0755, true) && false === is_dir($path)) { + throw new RuntimeException(r("Unable to create '{path}' directory.", ['path' => $path])); + } + } + + $dbFile = fixPath(r("{path}/{user}.db", ['path' => $path, 'user' => $user])); + $inTestMode = true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE); + $dsn = r('sqlite:{src}', ['src' => $inTestMode ? ':memory:' : $dbFile]); + if (false === $inTestMode) { + $changePerm = !file_exists($dbFile); + } + $pdo = new PDO(dsn: $dsn, options: Config::get('database.options', [])); + if (!$inTestMode && $changePerm && inContainer() && 777 !== (int)(decoct(fileperms($dbFile) & 0777))) { + @chmod($dbFile, 0777); + } + foreach (Config::get('database.exec', []) as $cmd) { + $pdo->exec($cmd); + } + + $db = Container::get(iDB::class)->with(db: Container::get(DBLayer::class)->withPDO($pdo)); + if (!$db->isMigrated()) { + $db->migrations(iDB::MIGRATE_UP); + $db->ensureIndex(); + $db->migrateData(Config::get('database.version'), Container::get(iLogger::class)); + } + + return $mapper->withDB($db); + } +} + +if (!function_exists('perUserCacheAdapter')) { + function perUserCacheAdapter(string $user): CacheInterface + { + if (true === (bool)env('WS_CACHE_NULL', false)) { + return new Psr16Cache(new NullAdapter()); + } + + if (true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE)) { + return new Psr16Cache(new ArrayAdapter()); + } + + $ns = getAppVersion(); + + if (true === isValidName($user)) { + $ns .= isValidName($user) ? '.' . $user : '.' . md5($user); + } + + 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); + } catch (Throwable) { + // -- in case of error, fallback to file system cache. + $path = fixPath(r("{path}/users/{user}/cache", ['path' => Config::get('path'), 'user' => $user])); + if (false === file_exists($path)) { + if (false === @mkdir($path, 0755, true) && false === is_dir($path)) { + throw new RuntimeException( + r("Unable to create per user cache '{path}' directory.", ['path' => $path]) + ); + } + } + $backend = new FilesystemAdapter(namespace: $ns, directory: $path); + } + + return new Psr16Cache($backend); + } +}