mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: revamp sync and sync commands
This commit is contained in:
parent
b12e0c14a7
commit
686c5f70fe
9 changed files with 174 additions and 79 deletions
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
56
app/Values/SyncResult.php
Normal 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();
|
||||
}
|
||||
}
|
37
app/Values/SyncResultCollection.php
Normal file
37
app/Values/SyncResultCollection.php
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue