feat: revamp sync and sync commands

This commit is contained in:
Phan An 2022-07-29 12:51:20 +02:00
parent b12e0c14a7
commit 686c5f70fe
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
9 changed files with 174 additions and 79 deletions

View file

@ -18,7 +18,6 @@ class PruneLibraryCommand extends Command
public function handle(): int
{
$this->libraryManager->prune();
$this->info('Empty artists and albums removed.');
return self::SUCCESS;

View file

@ -4,9 +4,10 @@ namespace App\Console\Commands;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Setting;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService;
use App\Values\SyncResult;
use Illuminate\Console\Command;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
class SyncCommand extends Command
@ -17,15 +18,18 @@ class SyncCommand extends Command
{--force : Force re-syncing even unchanged files}';
protected $description = 'Sync songs found in configured directory against the database.';
private int $skippedCount = 0;
private int $invalidCount = 0;
private int $syncedCount = 0;
private ?ProgressBar $progressBar = null;
private ProgressBar $progressBar;
public function __construct(private MediaSyncService $mediaSyncService)
{
parent::__construct();
$this->mediaSyncService->on('paths-gathered', function (array $paths): void {
$this->progressBar = new ProgressBar($this->output, count($paths));
});
$this->mediaSyncService->on('progress', [$this, 'onSyncProgress']);
}
public function handle(): int
@ -46,24 +50,27 @@ class SyncCommand extends Command
/**
* Sync all files in the configured media path.
*/
protected function syncAll(): void
private function syncAll(): void
{
$path = Setting::get('media_path');
$this->info('Syncing media from ' . $path . PHP_EOL);
// The excluded tags.
$this->components->info('Scanning ' . $path);
// The tags to ignore from syncing.
// Notice that this is only meaningful for existing records.
// New records will have every applicable field synced in.
$excludes = $this->option('excludes') ? explode(',', $this->option('excludes')) : [];
$ignores = $this->option('ignore') ? explode(',', $this->option('ignore')) : [];
$this->mediaSyncService->sync($excludes, $this->option('force'), $this);
$results = $this->mediaSyncService->sync($ignores, $this->option('force'));
$this->output->writeln(
PHP_EOL . PHP_EOL
. "<info>Completed! $this->syncedCount new or updated song(s)</info>, "
. "$this->skippedCount unchanged song(s), "
. "and <comment>$this->invalidCount invalid file(s)</comment>."
);
$this->newLine(2);
$this->components->info('Scanning completed!');
$this->components->bulletList([
"<fg=green>{$results->success()->count()}</> new or updated song(s)",
"<fg=yellow>{$results->skipped()->count()}</> unchanged song(s)",
"<fg=red>{$results->error()->count()}</> invalid file(s)",
]);
}
/**
@ -76,39 +83,33 @@ class SyncCommand extends Command
*
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
public function syncSingleRecord(string $record): void
private function syncSingleRecord(string $record): void
{
$this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record));
}
/**
* Log a song's sync status to console.
*/
public function logSyncStatusToConsole(string $path, int $result, ?string $reason = null): void
public function onSyncProgress(SyncResult $result): void
{
$name = basename($path);
if (!$this->option('verbose')) {
$this->progressBar->advance();
if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) {
++$this->skippedCount;
} elseif ($result === FileSynchronizer::SYNC_RESULT_BAD_FILE) {
if ($this->option('verbose')) {
$this->error(PHP_EOL . "'$name' is not a valid media file: $reason");
}
++$this->invalidCount;
} else {
++$this->syncedCount;
return;
}
}
public function createProgressBar(int $max): void
{
$this->progressBar = $this->getOutput()->createProgressBar($max);
}
$path = dirname($result->path);
$file = basename($result->path);
$sep = DIRECTORY_SEPARATOR;
public function advanceProgressBar(): void
{
$this->progressBar->advance();
$this->components->twoColumnDetail("<fg=gray>$path$sep</>$file", match (true) {
$result->isSuccess() => "<fg=green>OK</>",
$result->isSkipped() => "<fg=yellow>SKIPPED</>",
$result->isError() => "<fg=red>ERROR</>",
default => throw new RuntimeException("Unknown sync result type: {$result->type}")
});
if ($result->isError()) {
$this->output->writeln("<fg=red>$result->error</>");
}
}
private function ensureMediaPath(): void

View file

@ -2,14 +2,14 @@
namespace App\Events;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaSyncCompleted extends Event
{
use SerializesModels;
public function __construct(public SyncResult $result)
public function __construct(public SyncResultCollection $results)
{
}
}

View file

@ -6,6 +6,7 @@ use App\Events\MediaSyncCompleted;
use App\Models\Song;
use App\Repositories\SongRepository;
use App\Services\Helper;
use App\Values\SyncResult;
class DeleteNonExistingRecordsPostSync
{
@ -15,9 +16,9 @@ class DeleteNonExistingRecordsPostSync
public function handle(MediaSyncCompleted $event): void
{
$hashes = $event->result
->validEntries()
->map(static fn (string $path): string => Helper::getFileHash($path))
$hashes = $event->results
->valid()
->map(static fn (SyncResult $result) => Helper::getFileHash($result->path))
->merge($this->songRepository->getAllHostedOnS3()->pluck('id'))
->toArray();

View file

@ -7,6 +7,7 @@ use App\Models\Artist;
use App\Models\Song;
use App\Repositories\SongRepository;
use App\Values\SongScanInformation;
use App\Values\SyncResult;
use getID3;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Arr;
@ -72,16 +73,16 @@ class FileSynchronizer
* @param array<string> $ignores The tags to ignore/exclude (only taken into account if the song already exists)
* @param bool $force Whether to force syncing, even if the file is unchanged
*/
public function sync(array $ignores = [], bool $force = false): int
public function sync(array $ignores = [], bool $force = false): SyncResult
{
if (!$this->isFileNewOrChanged() && !$force) {
return self::SYNC_RESULT_UNMODIFIED;
return SyncResult::skipped($this->filePath);
}
$info = $this->getFileScanInformation()?->toArray();
if (!$info) {
return self::SYNC_RESULT_BAD_FILE;
return SyncResult::error($this->filePath, $this->syncError);
}
if (!$this->isFileNew()) {
@ -102,7 +103,7 @@ class FileSynchronizer
$this->song = Song::updateOrCreate(['id' => $this->fileHash], $data);
return self::SYNC_RESULT_SUCCESS;
return SyncResult::success($this->filePath);
}
/**

View file

@ -2,20 +2,22 @@
namespace App\Services;
use App\Console\Commands\SyncCommand;
use App\Events\LibraryChanged;
use App\Events\MediaSyncCompleted;
use App\Libraries\WatchRecord\WatchRecordInterface;
use App\Models\Song;
use App\Repositories\SettingRepository;
use App\Repositories\SongRepository;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use Psr\Log\LoggerInterface;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
class MediaSyncService
{
/** @var array<array-key, callable> */
private array $events = [];
public function __construct(
private SettingRepository $settingRepository,
private SongRepository $songRepository,
@ -30,46 +32,36 @@ class MediaSyncService
* Only taken into account for existing records.
* New records will have all tags synced in regardless.
* @param bool $force Whether to force syncing even unchanged files
* @param SyncCommand $syncCommand The SyncMedia command object, to log to console if executed by artisan
*/
public function sync(array $ignores = [], bool $force = false, ?SyncCommand $syncCommand = null): void
public function sync(array $ignores = [], bool $force = false): SyncResultCollection
{
/** @var string $mediaPath */
$mediaPath = $this->settingRepository->getByKey('media_path');
$this->setSystemRequirements();
$syncResult = SyncResult::init();
$results = SyncResultCollection::create();
$songPaths = $this->gatherFiles($mediaPath);
$syncCommand?->createProgressBar(count($songPaths));
if (isset($this->events['paths-gathered'])) {
$this->events['paths-gathered']($songPaths);
}
foreach ($songPaths as $path) {
$result = $this->fileSynchronizer->setFile($path)->sync($ignores, $force);
$results->add($result);
switch ($result) {
case FileSynchronizer::SYNC_RESULT_SUCCESS:
$syncResult->success->add($path);
break;
case FileSynchronizer::SYNC_RESULT_UNMODIFIED:
$syncResult->unmodified->add($path);
break;
default:
$syncResult->bad->add($path);
break;
}
if ($syncCommand) {
$syncCommand->advanceProgressBar();
$syncCommand->logSyncStatusToConsole($path, $result, $this->fileSynchronizer->getSyncError());
if (isset($this->events['progress'])) {
$this->events['progress']($result);
}
}
event(new MediaSyncCompleted($syncResult));
event(new MediaSyncCompleted($results));
// Trigger LibraryChanged, so that PruneLibrary handler is fired to prune the lib.
event(new LibraryChanged());
return $results;
}
/**
@ -150,7 +142,7 @@ class MediaSyncService
{
$result = $this->fileSynchronizer->setFile($path)->sync();
if ($result === FileSynchronizer::SYNC_RESULT_SUCCESS) {
if ($result->isSuccess()) {
$this->logger->info("Synchronized $path");
} else {
$this->logger->info("Failed to synchronized $path. Maybe an invalid file?");
@ -182,4 +174,9 @@ class MediaSyncService
event(new LibraryChanged());
}
public function on(string $event, callable $callback): void
{
$this->events[$event] = $callback;
}
}

56
app/Values/SyncResult.php Normal file
View file

@ -0,0 +1,56 @@
<?php
namespace App\Values;
use Webmozart\Assert\Assert;
final class SyncResult
{
public const TYPE_SUCCESS = 1;
public const TYPE_ERROR = 2;
public const TYPE_SKIPPED = 3;
private function __construct(public string $path, public int $type, public ?string $error)
{
Assert::oneOf($type, [
SyncResult::TYPE_SUCCESS,
SyncResult::TYPE_ERROR,
SyncResult::TYPE_SKIPPED,
]);
}
public static function success(string $path): self
{
return new self($path, self::TYPE_SUCCESS, null);
}
public static function skipped(string $path): self
{
return new self($path, self::TYPE_SKIPPED, null);
}
public static function error(string $path, ?string $error): self
{
return new self($path, self::TYPE_ERROR, $error);
}
public function isSuccess(): bool
{
return $this->type === self::TYPE_SUCCESS;
}
public function isSkipped(): bool
{
return $this->type === self::TYPE_SKIPPED;
}
public function isError(): bool
{
return $this->type === self::TYPE_ERROR;
}
public function isValid(): bool
{
return $this->isSuccess() || $this->isSkipped();
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Values;
use Illuminate\Support\Collection;
final class SyncResultCollection extends Collection
{
public static function create(): self
{
return new self();
}
/** @return Collection|array<array-key, SyncResult> */
public function valid(): Collection
{
return $this->filter(static fn (SyncResult $result): bool => $result->isValid());
}
/** @return Collection|array<array-key, SyncResult> */
public function success(): Collection
{
return $this->filter(static fn (SyncResult $result): bool => $result->isSuccess());
}
/** @return Collection|array<array-key, SyncResult> */
public function skipped(): Collection
{
return $this->filter(static fn (SyncResult $result): bool => $result->isSkipped());
}
/** @return Collection|array<array-key, SyncResult> */
public function error(): Collection
{
return $this->filter(static fn (SyncResult $result): bool => $result->isError());
}
}

View file

@ -6,6 +6,7 @@ use App\Events\MediaSyncCompleted;
use App\Listeners\DeleteNonExistingRecordsPostSync;
use App\Models\Song;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use Illuminate\Database\Eloquent\Collection;
use Tests\TestCase;
@ -23,7 +24,7 @@ class DeleteNonExistingRecordsPostSyncTest extends TestCase
public function testHandleDoesNotDeleteS3Entries(): void
{
$song = Song::factory()->create(['path' => 's3://do-not/delete-me.mp3']);
$this->listener->handle(new MediaSyncCompleted(SyncResult::init()));
$this->listener->handle(new MediaSyncCompleted(SyncResultCollection::create()));
self::assertModelExists($song);
}
@ -35,12 +36,14 @@ class DeleteNonExistingRecordsPostSyncTest extends TestCase
self::assertCount(4, Song::all());
$syncResult = SyncResult::init();
$syncResult->success->add($songs[0]->path);
$syncResult->unmodified->add($songs[3]->path);
$syncResult = SyncResultCollection::create();
$syncResult->add(SyncResult::success($songs[0]->path));
$syncResult->add(SyncResult::skipped($songs[3]->path));
$this->listener->handle(new MediaSyncCompleted($syncResult));
self::assertModelExists($songs[0]);
self::assertModelExists($songs[3]);
self::assertModelMissing($songs[1]);
self::assertModelMissing($songs[2]);
}