feat: rename (alias) koel:sync to koel:scan and add owner/private options

This commit is contained in:
Phan An 2024-01-04 22:51:32 +01:00
parent e0ca8e8bd5
commit 9d94df50a8
42 changed files with 406 additions and 334 deletions

View file

@ -11,6 +11,7 @@ use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Contracts\Hashing\Hasher as Hash;
use Illuminate\Database\DatabaseManager as DB;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Jackiedo\DotenvEditor\DotenvEditor;
use Psr\Log\LoggerInterface;
@ -344,7 +345,7 @@ class InitCommand extends Command
private static function isValidMediaPath(string $path): bool
{
return is_dir($path) && is_readable($path);
return File::isDirectory($path) && File::isReadable($path);
}
/**

View file

@ -4,68 +4,91 @@ namespace App\Console\Commands;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Setting;
use App\Services\MediaSyncService;
use App\Values\SyncResult;
use App\Models\User;
use App\Services\MediaScanner;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
class SyncCommand extends Command
class ScanCommand extends Command
{
protected $signature = 'koel:sync
protected $signature = 'koel:scan
{record? : A single watch record. Consult Wiki for more info.}
{--ignore=* : The comma-separated tags to ignore (exclude) from syncing}
{--force : Force re-syncing even unchanged files}';
{--O|owner= : The ID of the user who should own the newly scanned songs. Defaults to the first admin user.}
{--P|private : Whether to make the newly scanned songs private to the user.}
{--V|verbose : Show more details about the scanning process
{--I|ignore=* : The comma-separated tags to ignore (exclude) from scanning}
{--F|force : Force re-scanning even unchanged files}';
protected $description = 'Sync songs found in configured directory against the database.';
protected $description = 'Scan for songs in the configured directory.';
private ?string $mediaPath;
private ?User $owner;
private ProgressBar $progressBar;
public function __construct(private MediaSyncService $mediaSyncService)
public function __construct(private MediaScanner $mediaScanner)
{
parent::__construct();
$this->mediaSyncService->on('paths-gathered', function (array $paths): void {
$this->mediaScanner->on('paths-gathered', function (array $paths): void {
$this->progressBar = new ProgressBar($this->output, count($paths));
});
$this->mediaSyncService->on('progress', [$this, 'onSyncProgress']);
$this->mediaScanner->on('progress', [$this, 'onScanProgress']);
}
protected function configure(): void
{
parent::configure();
$this->setAliases(['koel:sync']);
}
public function handle(): int
{
$this->owner = $this->getOwner();
$this->mediaPath = $this->getMediaPath();
$record = $this->argument('record');
if ($record) {
$this->syncSingleRecord($record);
$this->scanSingleRecord($record);
} else {
$this->syncAll();
$this->scanMediaPath();
}
return self::SUCCESS;
}
/**
* Sync all files in the configured media path.
* Scan all files in the configured media path.
*/
private function syncAll(): void
private function scanMediaPath(): void
{
$this->components->info('Scanning ' . $this->mediaPath);
// The tags to ignore from syncing.
// The tags to ignore from scanning.
// Notice that this is only meaningful for existing records.
// New records will have every applicable field synced in.
// New records will have every applicable field scanned.
$ignores = collect($this->option('ignore'))->sort()->values()->all();
if ($ignores) {
$this->components->info('Ignoring tag(s): ' . implode(', ', $ignores));
}
$results = $this->mediaSyncService->sync($ignores, $this->option('force'));
$config = ScanConfiguration::make(
owner: $this->owner,
// When scanning via CLI, the songs should be public by default, unless explicitly specified otherwise.
makePublic: !$this->option('private'),
ignores: $ignores,
force: $this->option('force')
);
$results = $this->mediaScanner->scan($config);
$this->newLine(2);
$this->components->info('Scanning completed!');
@ -87,12 +110,12 @@ class SyncCommand extends Command
*
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
private function syncSingleRecord(string $record): void
private function scanSingleRecord(string $record): void
{
$this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record));
$this->mediaScanner->scanWatchRecord(new InotifyWatchRecord($record));
}
public function onSyncProgress(SyncResult $result): void
public function onScanProgress(ScanResult $result): void
{
if (!$this->option('verbose')) {
$this->progressBar->advance();
@ -106,7 +129,7 @@ class SyncCommand extends Command
$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}")
default => throw new RuntimeException("Unknown scan result type: {$result->type}")
});
if ($result->isError()) {
@ -127,7 +150,7 @@ class SyncCommand extends Command
while (true) {
$path = $this->ask('Absolute path to your media directory');
if (is_dir($path) && is_readable($path)) {
if (File::isDirectory($path) && File::isReadable($path)) {
Setting::set('media_path', $path);
break;
}
@ -137,4 +160,29 @@ class SyncCommand extends Command
return $path;
}
private function getOwner(): User
{
$specifiedOwner = $this->option('owner');
if ($specifiedOwner) {
/** @var User $user */
$user = User::findOr($specifiedOwner, function () use ($specifiedOwner): void {
$this->components->error("User with ID $specifiedOwner does not exist.");
exit(self::INVALID);
});
$this->components->info("Setting owner to $user->name (ID $user->id).");
return $user;
}
/** @var User $user */
$user = User::where('is_admin', true)->oldest()->firstOrFail();
$this->components->warn(
"No song owner specified. Setting the first admin ($user->name, ID $user->id) as owner."
);
return $user;
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Values\ScanResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaScanCompleted extends Event
{
use SerializesModels;
public function __construct(public ScanResultCollection $results)
{
}
}

View file

@ -1,15 +0,0 @@
<?php
namespace App\Events;
use App\Values\SyncResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaSyncCompleted extends Event
{
use SerializesModels;
public function __construct(public SyncResultCollection $results)
{
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class OwnerNotSetPriorToScanException extends Exception
{
private function __construct($message = '', $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public static function create(): self
{
return new static('An owner must be set prior to scanning, as a song must be owned by a user.');
}
}

View file

@ -1,5 +1,6 @@
<?php
use Illuminate\Support\Facades\File as FileFacade;
use Illuminate\Support\Facades\Log;
/**
@ -38,7 +39,7 @@ function artist_image_url(?string $fileName): ?string
function koel_version(): string
{
return trim(file_get_contents(base_path('.version')));
return trim(FileFacade::get(base_path('.version')));
}
/**

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\File;
use Throwable;
class FetchDemoCreditController extends Controller
@ -10,7 +11,7 @@ class FetchDemoCreditController extends Controller
public function __invoke()
{
try {
return response()->json(json_decode(file_get_contents(resource_path('demo-credits.json')), true));
return response()->json(json_decode(File::get(resource_path('demo-credits.json')), true));
} catch (Throwable) {
return response()->json();
}

View file

@ -6,11 +6,13 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\API\SettingRequest;
use App\Models\Setting;
use App\Models\User;
use App\Services\MediaSyncService;
use App\Services\MediaScanner;
use Illuminate\Contracts\Auth\Authenticatable;
class SettingController extends Controller
{
public function __construct(private MediaSyncService $mediaSyncService)
/** @param User $user */
public function __construct(private MediaScanner $mediaSyncService, private ?Authenticatable $user)
{
}
@ -19,7 +21,8 @@ class SettingController extends Controller
$this->authorize('admin', User::class);
Setting::set('media_path', rtrim(trim($request->media_path), '/'));
$this->mediaSyncService->sync();
$this->mediaSyncService->scan($this->user, makePublic: true);
return response()->noContent();
}

View file

@ -2,10 +2,10 @@
namespace App\Listeners;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
use App\Models\Song;
use App\Repositories\SongRepository;
use App\Values\SyncResult;
use App\Values\ScanResult;
class DeleteNonExistingRecordsPostSync
{
@ -13,11 +13,11 @@ class DeleteNonExistingRecordsPostSync
{
}
public function handle(MediaSyncCompleted $event): void
public function handle(MediaScanCompleted $event): void
{
$paths = $event->results
->valid()
->map(static fn (SyncResult $result) => $result->path)
->map(static fn (ScanResult $result) => $result->path)
->merge($this->songRepository->getAllHostedOnS3()->pluck('path'))
->toArray();

View file

@ -2,16 +2,16 @@
namespace App\Listeners;
use App\Events\MediaSyncCompleted;
use App\Values\SyncResult;
use App\Events\MediaScanCompleted;
use App\Values\ScanResult;
use Illuminate\Support\Collection;
use Throwable;
class WriteSyncLog
{
public function handle(MediaSyncCompleted $event): void
public function handle(MediaScanCompleted $event): void
{
$transformer = static fn (SyncResult $entry) => (string) $entry;
$transformer = static fn (ScanResult $entry) => (string) $entry;
/** @var Collection $messages */
$messages = config('koel.sync_log_level') === 'all'

View file

@ -3,7 +3,7 @@
namespace App\Providers;
use App\Events\LibraryChanged;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
use App\Events\PlaybackStarted;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
@ -42,7 +42,7 @@ class EventServiceProvider extends BaseServiceProvider
PruneLibrary::class,
],
MediaSyncCompleted::class => [
MediaScanCompleted::class => [
DeleteNonExistingRecordsPostSync::class,
WriteSyncLog::class,
],

View file

@ -2,21 +2,20 @@
namespace App\Services;
use App\Exceptions\OwnerNotSetPriorToScanException;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use App\Values\SongScanInformation;
use App\Values\SyncResult;
use getID3;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Arr;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
class FileSynchronizer
class FileScanner
{
private ?int $fileModifiedTime = null;
private ?string $filePath = null;
@ -26,8 +25,6 @@ class FileSynchronizer
*/
private ?Song $song;
private ?User $owner = null;
private ?string $syncError = null;
public function __construct(
@ -51,13 +48,6 @@ class FileSynchronizer
return $this;
}
public function setOwner(User $owner): static
{
$this->owner = $owner;
return $this;
}
public function getFileScanInformation(): ?SongScanInformation
{
$raw = $this->getID3->analyze($this->filePath);
@ -75,54 +65,49 @@ class FileSynchronizer
return $info;
}
/**
* Sync the song with all available media info into the database.
*
* @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): SyncResult
public function scan(ScanConfiguration $config): ScanResult
{
if (!$this->owner) {
throw OwnerNotSetPriorToScanException::create();
}
if (!$this->isFileNewOrChanged() && !$force) {
return SyncResult::skipped($this->filePath);
if (!$this->isFileNewOrChanged() && !$config->force) {
return ScanResult::skipped($this->filePath);
}
$info = $this->getFileScanInformation()?->toArray();
if (!$info) {
return SyncResult::error($this->filePath, $this->syncError);
return ScanResult::error($this->filePath, $this->syncError);
}
if (!$this->isFileNew()) {
Arr::forget($info, $ignores);
Arr::forget($info, $config->ignores);
}
$artist = Arr::get($info, 'artist') ? Artist::getOrCreate($info['artist']) : $this->song->artist;
$albumArtist = Arr::get($info, 'albumartist') ? Artist::getOrCreate($info['albumartist']) : $artist;
$album = Arr::get($info, 'album') ? Album::getOrCreate($albumArtist, $info['album']) : $this->song->album;
if (!in_array('cover', $ignores, true) && !$album->has_cover) {
if (!in_array('cover', $config->ignores, true) && !$album->has_cover) {
$this->tryGenerateAlbumCover($album, Arr::get($info, 'cover', []));
}
$data = Arr::except($info, ['album', 'artist', 'albumartist', 'cover']);
$data['album_id'] = $album->id;
$data['artist_id'] = $artist->id;
$data['owner_id'] = $this->owner->id;
$data['is_public'] = $config->makePublic;
if ($this->isFileNew()) {
// Only set the owner if the song is new i.e. don't override the owner if the song is being updated.
$data['owner_id'] = $config->owner->id;
}
$this->song = Song::query()->updateOrCreate(['path' => $this->filePath], $data); // @phpstan-ignore-line
return SyncResult::success($this->filePath);
return ScanResult::success($this->filePath);
}
/**
* Try to generate a cover for an album based on extracted data, or use the cover file under the directory.
*
* @param array<mixed>|null $coverData
* @param ?array<mixed> $coverData
*/
private function tryGenerateAlbumCover(Album $album, ?array $coverData): void
{

View file

@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class MediaMetadataService
@ -72,7 +73,7 @@ class MediaMetadataService
$this->imageWriter->write($destination, $source);
if ($cleanUp && $artist->has_image) {
@unlink($artist->image_path);
File::delete($artist->image_path);
}
$artist->update(['image' => basename($destination)]);
@ -99,7 +100,7 @@ class MediaMetadataService
return null;
}
if (!file_exists($album->thumbnail_path)) {
if (!File::exists($album->thumbnail_path)) {
$this->createThumbnailForAlbum($album);
}
@ -117,7 +118,7 @@ class MediaMetadataService
return;
}
@unlink($album->cover_path);
@unlink($album->thumbnail_path);
File::delete($album->cover_path);
File::delete($album->thumbnail_path);
}
}

View file

@ -3,19 +3,20 @@
namespace App\Services;
use App\Events\LibraryChanged;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
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 App\Values\ScanConfiguration;
use App\Values\ScanResult;
use App\Values\ScanResultCollection;
use Psr\Log\LoggerInterface;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
use Throwable;
class MediaSyncService
class MediaScanner
{
/** @var array<array-key, callable> */
private array $events = [];
@ -23,26 +24,20 @@ class MediaSyncService
public function __construct(
private SettingRepository $settingRepository,
private SongRepository $songRepository,
private FileSynchronizer $fileSynchronizer,
private FileScanner $fileScanner,
private Finder $finder,
private LoggerInterface $logger
) {
}
/**
* @param array<string> $ignores The tags to ignore.
* 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
*/
public function sync(array $ignores = [], bool $force = false): SyncResultCollection
public function scan(ScanConfiguration $config): ScanResultCollection
{
/** @var string $mediaPath */
$mediaPath = $this->settingRepository->getByKey('media_path');
$this->setSystemRequirements();
$results = SyncResultCollection::create();
$results = ScanResultCollection::create();
$songPaths = $this->gatherFiles($mediaPath);
if (isset($this->events['paths-gathered'])) {
@ -51,9 +46,9 @@ class MediaSyncService
foreach ($songPaths as $path) {
try {
$result = $this->fileSynchronizer->setFile($path)->sync($ignores, $force);
$result = $this->fileScanner->setFile($path)->scan($config);
} catch (Throwable) {
$result = SyncResult::error($path, 'Possible invalid file');
$result = ScanResult::error($path, 'Possible invalid file');
}
$results->add($result);
@ -63,7 +58,7 @@ class MediaSyncService
}
}
event(new MediaSyncCompleted($results));
event(new MediaScanCompleted($results));
// Trigger LibraryChanged, so that PruneLibrary handler is fired to prune the lib.
event(new LibraryChanged());
@ -83,7 +78,7 @@ class MediaSyncService
return iterator_to_array(
$this->finder->create()
->ignoreUnreadableDirs()
->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/koel/koel/issues/450
->ignoreDotFiles((bool)config('koel.ignore_dot_files')) // https://github.com/koel/koel/issues/450
->files()
->followLinks()
->name('/\.(mp3|wav|ogg|m4a|flac|opus)$/i')
@ -91,13 +86,18 @@ class MediaSyncService
);
}
public function syncByWatchRecord(WatchRecordInterface $record): void
public function scanWatchRecord(WatchRecordInterface $record, ScanConfiguration $config): void
{
$this->logger->info("New watch record received: '{$record->getPath()}'");
$record->isFile() ? $this->syncFileRecord($record) : $this->syncDirectoryRecord($record);
if ($record->isFile()) {
$this->scanFileRecord($record, $config);
} else {
$this->scanDirectoryRecord($record, $config);
}
}
private function syncFileRecord(WatchRecordInterface $record): void
private function scanFileRecord(WatchRecordInterface $record, ScanConfiguration $config): void
{
$path = $record->getPath();
$this->logger->info("'$path' is a file.");
@ -105,11 +105,11 @@ class MediaSyncService
if ($record->isDeleted()) {
$this->handleDeletedFileRecord($path);
} elseif ($record->isNewOrModified()) {
$this->handleNewOrModifiedFileRecord($path);
$this->handleNewOrModifiedFileRecord($path, $config);
}
}
private function syncDirectoryRecord(WatchRecordInterface $record): void
private function scanDirectoryRecord(WatchRecordInterface $record, ScanConfiguration $config): void
{
$path = $record->getPath();
$this->logger->info("'$path' is a directory.");
@ -117,7 +117,7 @@ class MediaSyncService
if ($record->isDeleted()) {
$this->handleDeletedDirectoryRecord($path);
} elseif ($record->isNewOrModified()) {
$this->handleNewOrModifiedDirectoryRecord($path);
$this->handleNewOrModifiedDirectoryRecord($path, $config);
}
}
@ -145,14 +145,14 @@ class MediaSyncService
}
}
private function handleNewOrModifiedFileRecord(string $path): void
private function handleNewOrModifiedFileRecord(string $path, ScanConfiguration $config): void
{
$result = $this->fileSynchronizer->setFile($path)->sync();
$result = $this->fileScanner->setFile($path)->scan($config);
if ($result->isSuccess()) {
$this->logger->info("Synchronized $path");
$this->logger->info("Scanned $path");
} else {
$this->logger->info("Failed to synchronized $path. Maybe an invalid file?");
$this->logger->info("Failed to scan $path. Maybe an invalid file?");
}
event(new LibraryChanged());
@ -171,21 +171,21 @@ class MediaSyncService
}
}
private function handleNewOrModifiedDirectoryRecord(string $path): void
private function handleNewOrModifiedDirectoryRecord(string $path, ScanConfiguration $config): void
{
$syncResults = SyncResultCollection::create();
$scanResults = ScanResultCollection::create();
foreach ($this->gatherFiles($path) as $file) {
try {
$syncResults->add($this->fileSynchronizer->setFile($file)->sync());
$scanResults->add($this->fileScanner->setFile($file)->scan($config));
} catch (Throwable) {
$syncResults->add(SyncResult::error($file->getRealPath(), 'Possible invalid file'));
$scanResults->add(ScanResult::error($file->getRealPath(), 'Possible invalid file'));
}
}
$this->logger->info("Synced all song(s) under $path");
$this->logger->info("Scanned all song(s) under $path");
event(new MediaSyncCompleted($syncResults));
event(new MediaScanCompleted($scanResults));
event(new LibraryChanged());
}

View file

@ -24,7 +24,7 @@ class QueueService
$currentSong = $state->current_song_id ? $this->songRepository->findOne($state->current_song_id, $user) : null;
return QueueStateDTO::create(
return QueueStateDTO::make(
$this->songRepository->getMany(ids: $state->song_ids, inThatOrder: true, scopedUser: $user),
$currentSong,
$state->playback_position ?? 0

View file

@ -2,6 +2,7 @@
namespace App\Services;
use Illuminate\Support\Facades\File;
use Throwable;
class SimpleLrcReader
@ -11,7 +12,7 @@ class SimpleLrcReader
$lrcFilePath = self::getLrcFilePath($mediaFilePath);
try {
return $lrcFilePath ? trim(file_get_contents($lrcFilePath)) : '';
return $lrcFilePath ? trim(File::get($lrcFilePath)) : '';
} catch (Throwable) {
return '';
}
@ -22,7 +23,7 @@ class SimpleLrcReader
foreach (['.lrc', '.LRC'] as $extension) {
$lrcFilePath = preg_replace('/\.[^.]+$/', $extension, $mediaFilePath);
if (is_file($lrcFilePath) && is_readable($lrcFilePath)) {
if (File::isFile($lrcFilePath) && File::isReadable($lrcFilePath)) {
return $lrcFilePath;
}
}

View file

@ -7,14 +7,16 @@ use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
use App\Values\ScanConfiguration;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Throwable;
use function Functional\memoize;
class UploadService
{
public function __construct(private FileSynchronizer $fileSynchronizer)
public function __construct(private FileScanner $scanner)
{
}
@ -27,21 +29,18 @@ class UploadService
$targetPathName = $uploadDirectory . $targetFileName;
try {
$result = $this->fileSynchronizer
->setOwner($uploader)
->setFile($targetPathName)
->sync();
$result = $this->scanner->setFile($targetPathName)->scan(ScanConfiguration::make(owner: $uploader));
} catch (Throwable) {
@unlink($targetPathName);
File::delete($targetPathName);
throw new SongUploadFailedException('Unknown error');
}
if ($result->isError()) {
@unlink($targetPathName);
File::delete($targetPathName);
throw new SongUploadFailedException($result->error);
}
return $this->fileSynchronizer->getSong();
return $this->scanner->getSong();
}
private function getUploadDirectory(User $uploader): string
@ -59,9 +58,7 @@ class UploadService
DIRECTORY_SEPARATOR
);
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
File::ensureDirectoryExists($dir);
return $dir;
});

View file

@ -5,14 +5,14 @@ namespace App\Values;
use App\Models\Song;
use Illuminate\Support\Collection;
class QueueState
final class QueueState
{
private function __construct(public Collection $songs, public ?Song $currentSong, public ?int $playbackPosition)
{
}
public static function create(Collection $songs, ?Song $currentSong = null, ?int $playbackPosition = 0): static
public static function make(Collection $songs, ?Song $currentSong = null, ?int $playbackPosition = 0): self
{
return new static($songs, $currentSong, $playbackPosition);
return new self($songs, $currentSong, $playbackPosition);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Values;
use App\Models\User;
final class ScanConfiguration
{
/**
* @param User $owner The user who owns the song
* @param bool $makePublic Whether to make the song public
* @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
*/
private function __construct(public User $owner, public bool $makePublic, public array $ignores, public bool $force)
{
}
public static function make(
User $owner,
bool $makePublic = false,
array $ignores = [],
bool $force = false
): self {
return new self($owner, $makePublic, $ignores, $force);
}
}

View file

@ -5,7 +5,7 @@ namespace App\Values;
use Exception;
use Webmozart\Assert\Assert;
final class SyncResult
final class ScanResult
{
public const TYPE_SUCCESS = 1;
public const TYPE_ERROR = 2;
@ -14,9 +14,9 @@ final class SyncResult
private function __construct(public string $path, public int $type, public ?string $error = null)
{
Assert::oneOf($type, [
SyncResult::TYPE_SUCCESS,
SyncResult::TYPE_ERROR,
SyncResult::TYPE_SKIPPED,
ScanResult::TYPE_SUCCESS,
ScanResult::TYPE_ERROR,
ScanResult::TYPE_SKIPPED,
]);
}

View file

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

View file

@ -91,7 +91,7 @@ final class SmartPlaylistRule implements Arrayable
Assert::countBetween($config['value'], 1, 2);
}
public static function create(array $config): self
public static function make(array $config): self
{
return new self($config);
}
@ -110,7 +110,7 @@ final class SmartPlaylistRule implements Arrayable
public function equals(array|self $rule): bool
{
if (is_array($rule)) {
$rule = self::create($rule);
$rule = self::make($rule);
}
return $this->operator === $rule->operator

View file

@ -14,11 +14,11 @@ final class SmartPlaylistRuleGroup implements Arrayable
Assert::uuid($id);
}
public static function create(array $array): self
public static function make(array $array): self
{
return new self(
id: Arr::get($array, 'id'),
rules: collect(Arr::get($array, 'rules', []))->transform([SmartPlaylistRule::class, 'create']),
rules: collect(Arr::get($array, 'rules', []))->transform([SmartPlaylistRule::class, 'make']),
);
}

View file

@ -8,6 +8,6 @@ final class SmartPlaylistRuleGroupCollection extends Collection
{
public static function create(array $array): self
{
return new self(collect($array)->transform([SmartPlaylistRuleGroup::class, 'create']));
return new self(collect($array)->transform([SmartPlaylistRuleGroup::class, 'make']));
}
}

View file

@ -1,37 +0,0 @@
<?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

@ -61,7 +61,7 @@ class PlaylistTest extends TestCase
/** @var User $user */
$user = User::factory()->create();
$rule = SmartPlaylistRule::create([
$rule = SmartPlaylistRule::make([
'model' => 'artist.name',
'operator' => SmartPlaylistRule::OPERATOR_IS,
'value' => ['Bob Dylan'],
@ -96,7 +96,7 @@ class PlaylistTest extends TestCase
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'rules' => [
SmartPlaylistRule::create([
SmartPlaylistRule::make([
'model' => 'artist.name',
'operator' => SmartPlaylistRule::OPERATOR_IS,
'value' => ['Bob Dylan'],

View file

@ -4,19 +4,19 @@ namespace Tests\Feature;
use App\Models\Setting;
use App\Models\User;
use App\Services\MediaSyncService;
use App\Values\SyncResultCollection;
use App\Services\MediaScanner;
use App\Values\ScanResultCollection;
use Mockery\MockInterface;
class SettingTest extends TestCase
{
private MediaSyncService|MockInterface $mediaSyncService;
private MediaScanner|MockInterface $mediaSyncService;
public function setUp(): void
{
parent::setUp();
$this->mediaSyncService = self::mock(MediaSyncService::class);
$this->mediaSyncService = self::mock(MediaScanner::class);
}
public function testSaveSettings(): void
@ -25,7 +25,7 @@ class SettingTest extends TestCase
$admin = User::factory()->admin()->create();
$this->mediaSyncService->shouldReceive('sync')->once()
->andReturn(SyncResultCollection::create());
->andReturn(ScanResultCollection::create());
$this->putAs('/api/settings', ['media_path' => __DIR__], $admin)
->assertSuccessful();

View file

@ -6,34 +6,25 @@ use App\Events\LibraryChanged;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
use App\Services\UploadService;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Mockery\MockInterface;
class UploadTest extends TestCase
{
private UploadService|MockInterface $uploadService;
private UploadedFile $file;
public function setUp(): void
{
parent::setUp();
$this->uploadService = self::mock(UploadService::class);
$this->file = UploadedFile::fromFile(__DIR__ . '/../songs/full.mp3', 'song.mp3');
}
public function testUnauthorizedPost(): void
{
Setting::set('media_path', '/media/koel');
$this->uploadService
->shouldReceive('handleUploadedFile')
->never();
Setting::set('media_path');
$this->postAs('/api/upload', ['file' => $this->file])->assertForbidden();
}
@ -47,38 +38,24 @@ class UploadTest extends TestCase
];
}
/** @dataProvider provideUploadExceptions */
public function testPostShouldFail(string $exceptionClass, int $statusCode): void
public function testUploadFailsIfMediaPathIsNotSet(): void
{
Setting::set('media_path');
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($this->file)
->andThrow($exceptionClass);
$this->postAs('/api/upload', ['file' => $this->file], $admin)->assertStatus($statusCode);
$this->postAs('/api/upload', ['file' => $this->file], $admin)->assertForbidden();
}
public function testPost(): void
public function testUploadSuccessful(): void
{
Event::fake(LibraryChanged::class);
Setting::set('media_path', '/media/koel');
/** @var Song $song */
$song = Song::factory()->create();
Setting::set('media_path', public_path('sandbox/media'));
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($this->file)
->andReturn($song);
$this->postAs('/api/upload', ['file' => $this->file], $admin)->assertJsonStructure(['song', 'album']);
Event::assertDispatched(LibraryChanged::class);
}

View file

@ -2,11 +2,11 @@
namespace Tests\Integration\Listeners;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
use App\Listeners\DeleteNonExistingRecordsPostSync;
use App\Models\Song;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use App\Values\ScanResult;
use App\Values\ScanResultCollection;
use Illuminate\Database\Eloquent\Collection;
use Tests\TestCase;
@ -24,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(SyncResultCollection::create()));
$this->listener->handle(new MediaScanCompleted(ScanResultCollection::create()));
self::assertModelExists($song);
}
@ -36,11 +36,11 @@ class DeleteNonExistingRecordsPostSyncTest extends TestCase
self::assertCount(4, Song::all());
$syncResult = SyncResultCollection::create();
$syncResult->add(SyncResult::success($songs[0]->path));
$syncResult->add(SyncResult::skipped($songs[3]->path));
$syncResult = ScanResultCollection::create();
$syncResult->add(ScanResult::success($songs[0]->path));
$syncResult->add(ScanResult::skipped($songs[3]->path));
$this->listener->handle(new MediaSyncCompleted($syncResult));
$this->listener->handle(new MediaScanCompleted($syncResult));
self::assertModelExists($songs[0]);
self::assertModelExists($songs[3]);

View file

@ -8,6 +8,7 @@ use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class ApplicationInformationServiceTest extends TestCase
@ -17,7 +18,7 @@ class ApplicationInformationServiceTest extends TestCase
$latestVersion = 'v1.1.2';
$mock = new MockHandler([
new Response(200, [], file_get_contents(__DIR__ . '../../../blobs/github-tags.json')),
new Response(200, [], File::get(__DIR__ . '../../../blobs/github-tags.json')),
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);

View file

@ -2,24 +2,25 @@
namespace Tests\Integration\Services;
use App\Services\FileSynchronizer;
use App\Services\FileScanner;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Tests\TestCase;
class FileSynchronizerTest extends TestCase
class FileScannerTest extends TestCase
{
private FileSynchronizer $fileSynchronizer;
private FileScanner $scanner;
public function setUp(): void
{
parent::setUp();
$this->fileSynchronizer = app(FileSynchronizer::class);
$this->scanner = app(FileScanner::class);
}
public function testGetFileInfo(): void
{
$info = $this->fileSynchronizer->setFile(__DIR__ . '/../../songs/full.mp3')->getFileScanInformation();
$info = $this->scanner->setFile(__DIR__ . '/../../songs/full.mp3')->getFileScanInformation();
$expectedData = [
'artist' => 'Koel',
@ -29,7 +30,7 @@ class FileSynchronizerTest extends TestCase
'disc' => 3,
'lyrics' => "Foo\rbar",
'cover' => [
'data' => file_get_contents(__DIR__ . '/../../blobs/cover.png'),
'data' => File::get(__DIR__ . '/../../blobs/cover.png'),
'image_mime' => 'image/png',
'image_width' => 512,
'image_height' => 512,
@ -50,7 +51,7 @@ class FileSynchronizerTest extends TestCase
public function testGetFileInfoVorbisCommentsFlac(): void
{
$flacPath = __DIR__ . '/../../songs/full-vorbis-comments.flac';
$info = $this->fileSynchronizer->setFile($flacPath)->getFileScanInformation();
$info = $this->scanner->setFile($flacPath)->getFileScanInformation();
$expectedData = [
'artist' => 'Koel',
@ -61,7 +62,7 @@ class FileSynchronizerTest extends TestCase
'disc' => 3,
'lyrics' => "Foo\r\nbar",
'cover' => [
'data' => file_get_contents(__DIR__ . '/../../blobs/cover.png'),
'data' => File::get(__DIR__ . '/../../blobs/cover.png'),
'image_mime' => 'image/png',
'image_width' => 512,
'image_height' => 512,
@ -78,9 +79,9 @@ class FileSynchronizerTest extends TestCase
public function testSongWithoutTitleHasFileNameAsTitle(): void
{
$this->fileSynchronizer->setFile(__DIR__ . '/../../songs/blank.mp3');
$this->scanner->setFile(__DIR__ . '/../../songs/blank.mp3');
self::assertSame('blank', $this->fileSynchronizer->getFileScanInformation()->title);
self::assertSame('blank', $this->scanner->getFileScanInformation()->title);
}
public function testIgnoreLrcFileIfEmbeddedLyricsAvailable(): void
@ -91,7 +92,7 @@ class FileSynchronizerTest extends TestCase
copy(__DIR__ . '/../../songs/full.mp3', $mediaFile);
copy(__DIR__ . '/../../blobs/simple.lrc', $lrcFile);
self::assertSame("Foo\rbar", $this->fileSynchronizer->setFile($mediaFile)->getFileScanInformation()->lyrics);
self::assertSame("Foo\rbar", $this->scanner->setFile($mediaFile)->getFileScanInformation()->lyrics);
}
public function testReadLrcFileIfEmbeddedLyricsNotAvailable(): void
@ -102,7 +103,7 @@ class FileSynchronizerTest extends TestCase
copy(__DIR__ . '/../../songs/blank.mp3', $mediaFile);
copy(__DIR__ . '/../../blobs/simple.lrc', $lrcFile);
$info = $this->fileSynchronizer->setFile($mediaFile)->getFileScanInformation();
$info = $this->scanner->setFile($mediaFile)->getFileScanInformation();
self::assertSame("Line 1\nLine 2\nLine 3", $info->lyrics);
}

View file

@ -4,6 +4,7 @@ namespace Tests\Integration\Services;
use App\Models\Album;
use App\Services\MediaMetadataService;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class MediaMetadataServiceTest extends TestCase
@ -17,7 +18,7 @@ class MediaMetadataServiceTest extends TestCase
public function testGetAlbumThumbnailUrl(): void
{
copy(__DIR__ . '/../../blobs/cover.png', album_cover_path('album-cover-for-thumbnail-test.jpg'));
File::copy(__DIR__ . '/../../blobs/cover.png', album_cover_path('album-cover-for-thumbnail-test.jpg'));
/** @var Album $album */
$album = Album::factory()->create(['cover' => 'album-cover-for-thumbnail-test.jpg']);
@ -39,8 +40,8 @@ class MediaMetadataServiceTest extends TestCase
private function cleanUp(): void
{
@unlink(album_cover_path('album-cover-for-thumbnail-test.jpg'));
@unlink(album_cover_path('album-cover-for-thumbnail-test_thumb.jpg'));
File::delete(album_cover_path('album-cover-for-thumbnail-test.jpg'));
File::delete(album_cover_path('album-cover-for-thumbnail-test_thumb.jpg'));
self::assertFileDoesNotExist(album_cover_path('album-cover-for-thumbnail-test.jpg'));
self::assertFileDoesNotExist(album_cover_path('album-cover-for-thumbnail-test_thumb.jpg'));

View file

@ -3,29 +3,31 @@
namespace Tests\Integration\Services;
use App\Events\LibraryChanged;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Setting;
use App\Models\Song;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService;
use App\Models\User;
use App\Services\FileScanner;
use App\Services\MediaScanner;
use App\Values\ScanConfiguration;
use getID3;
use Illuminate\Support\Arr;
use Mockery;
use Tests\Feature\TestCase;
class MediaSyncServiceTest extends TestCase
class MediaScannerTest extends TestCase
{
private MediaSyncService $mediaService;
private MediaScanner $scanner;
public function setUp(): void
{
parent::setUp();
Setting::set('media_path', realpath($this->mediaPath));
$this->mediaService = app(MediaSyncService::class);
$this->scanner = app(MediaScanner::class);
}
private function path($subPath): string
@ -33,20 +35,27 @@ class MediaSyncServiceTest extends TestCase
return realpath($this->mediaPath . $subPath);
}
public function testSync(): void
public function testScan(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
/** @var User $owner */
$owner = User::factory()->admin()->create();
$this->mediaService->sync();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan(ScanConfiguration::make(owner: $owner));
// Standard mp3 files under root path should be recognized
self::assertDatabaseHas(Song::class, [
'path' => $this->path('/full.mp3'),
'track' => 5,
'owner_id' => $owner->id,
]);
// Ogg files and audio files in subdirectories should be recognized
self::assertDatabaseHas(Song::class, ['path' => $this->path('/subdir/back-in-black.ogg')]);
self::assertDatabaseHas(Song::class, [
'path' => $this->path('/subdir/back-in-black.ogg'),
'owner_id' => $owner->id,
]);
// GitHub issue #380. folder.png should be copied and used as the cover for files
// under subdir/
@ -83,26 +92,30 @@ class MediaSyncServiceTest extends TestCase
self::assertSame('Cuckoo', $song->artist->name);
}
public function testModifiedFileIsResynced(): void
public function testModifiedFileIsRescanned(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
$config = ScanConfiguration::make(owner: User::factory()->admin()->create());
$this->mediaService->sync();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan($config);
/** @var Song $song */
$song = Song::query()->first();
touch($song->path, $time = time() + 1000);
$this->mediaService->sync();
$this->scanner->scan($config);
self::assertSame($time, $song->refresh()->mtime);
}
public function testResyncWithoutForceDoesNotResetData(): void
public function testRescanWithoutForceDoesNotResetData(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
$config = ScanConfiguration::make(owner: User::factory()->admin()->create());
$this->mediaService->sync();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan($config);
/** @var Song $song */
$song = Song::query()->first();
@ -112,18 +125,21 @@ class MediaSyncServiceTest extends TestCase
'lyrics' => 'Booom Wroooom',
]);
$this->mediaService->sync();
$this->scanner->scan($config);
$song->refresh();
self::assertSame("It's John Cena!", $song->title);
self::assertSame('Booom Wroooom', $song->lyrics);
}
public function testForceSyncResetsData(): void
public function testForceScanResetsData(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
/** @var User $owner */
$owner = User::factory()->admin()->create();
$this->mediaService->sync();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan(ScanConfiguration::make(owner: $owner));
/** @var Song $song */
$song = Song::query()->first();
@ -133,19 +149,24 @@ class MediaSyncServiceTest extends TestCase
'lyrics' => 'Booom Wroooom',
]);
$this->mediaService->sync(force: true);
$this->scanner->scan(ScanConfiguration::make(owner: User::factory()->admin()->create(), force: true));
$song->refresh();
self::assertNotSame("It's John Cena!", $song->title);
self::assertNotSame('Booom Wroooom', $song->lyrics);
// make sure the user is not changed
self::assertSame($owner->id, $song->owner_id);
}
public function testSyncWithIgnoredTags(): void
public function testScanWithIgnoredTags(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
/** @var User $owner */
$owner = User::factory()->admin()->create();
$this->mediaService->sync();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan(ScanConfiguration::make(owner: $owner));
/** @var Song $song */
$song = Song::query()->first();
@ -155,7 +176,7 @@ class MediaSyncServiceTest extends TestCase
'lyrics' => 'Booom Wroooom',
]);
$this->mediaService->sync(ignores: ['title'], force: true);
$this->scanner->scan(ScanConfiguration::make(owner: $owner, ignores: ['title'], force: true));
$song->refresh();
@ -163,17 +184,24 @@ class MediaSyncServiceTest extends TestCase
self::assertNotSame('Booom Wroooom', $song->lyrics);
}
public function testSyncAllTagsForNewFilesRegardlessOfIgnoredOption(): void
public function testScanAllTagsForNewFilesRegardlessOfIgnoredOption(): void
{
$this->expectsEvents(MediaSyncCompleted::class);
$this->mediaService->sync();
/** @var User $owner */
$owner = User::factory()->admin()->create();
$this->expectsEvents(MediaScanCompleted::class);
$this->scanner->scan(ScanConfiguration::make(owner: $owner));
/** @var Song $song */
$song = Song::query()->first();
$song->delete();
$this->mediaService->sync(ignores: ['title', 'disc', 'track'], force: true);
$this->scanner->scan(ScanConfiguration::make(
owner: $owner,
ignores: ['title', 'disc', 'track'],
force: true
));
// Song should be added back with all info
self::assertEquals(
@ -182,17 +210,21 @@ class MediaSyncServiceTest extends TestCase
);
}
public function testSyncAddedSongViaWatch(): void
public function testScanAddedSongViaWatch(): void
{
$this->expectsEvents(LibraryChanged::class);
$path = $this->path('/blank.mp3');
$this->mediaService->syncByWatchRecord(new InotifyWatchRecord("CLOSE_WRITE,CLOSE $path"));
$this->scanner->scanWatchRecord(
new InotifyWatchRecord("CLOSE_WRITE,CLOSE $path"),
ScanConfiguration::make(owner: User::factory()->admin()->create())
);
self::assertDatabaseHas(Song::class, ['path' => $path]);
}
public function testSyncDeletedSongViaWatch(): void
public function testScanDeletedSongViaWatch(): void
{
$this->expectsEvents(LibraryChanged::class);
@ -201,18 +233,22 @@ class MediaSyncServiceTest extends TestCase
/** @var Song $song */
$song = Song::query()->first();
$this->mediaService->syncByWatchRecord(new InotifyWatchRecord("DELETE $song->path"));
$this->scanner->scanWatchRecord(
new InotifyWatchRecord("DELETE $song->path"),
ScanConfiguration::make(owner: User::factory()->admin()->create())
);
self::assertModelMissing($song);
}
public function testSyncDeletedDirectoryViaWatch(): void
public function testScanDeletedDirectoryViaWatch(): void
{
$this->expectsEvents(LibraryChanged::class, MediaSyncCompleted::class);
$config = ScanConfiguration::make(owner: User::factory()->admin()->create());
$this->mediaService->sync();
$this->expectsEvents(LibraryChanged::class, MediaScanCompleted::class);
$this->mediaService->syncByWatchRecord(new InotifyWatchRecord("MOVED_FROM,ISDIR $this->mediaPath/subdir"));
$this->scanner->scan($config);
$this->scanner->scanWatchRecord(new InotifyWatchRecord("MOVED_FROM,ISDIR $this->mediaPath/subdir"), $config);
self::assertDatabaseMissing(Song::class, ['path' => $this->path('/subdir/sic.mp3')]);
self::assertDatabaseMissing(Song::class, ['path' => $this->path('/subdir/no-name.mp3')]);
@ -243,9 +279,9 @@ class MediaSyncServiceTest extends TestCase
])
);
/** @var FileSynchronizer $fileSynchronizer */
$fileSynchronizer = app(FileSynchronizer::class);
$info = $fileSynchronizer->setFile($path)->getFileScanInformation();
/** @var FileScanner $fileScanner */
$fileScanner = app(FileScanner::class);
$info = $fileScanner->setFile($path)->getFileScanInformation();
self::assertSame('佐倉綾音 Unknown', $info->artistName);
self::assertSame('小岩井こ Random', $info->albumName);
@ -254,12 +290,14 @@ class MediaSyncServiceTest extends TestCase
public function testOptionallyIgnoreHiddenFiles(): void
{
$config = ScanConfiguration::make(owner: User::factory()->admin()->create());
config(['koel.ignore_dot_files' => false]);
$this->mediaService->sync();
$this->scanner->scan($config);
self::assertDatabaseHas(Album::class, ['name' => 'Hidden Album']);
config(['koel.ignore_dot_files' => true]);
$this->mediaService->sync();
$this->scanner->scan($config);
self::assertDatabaseMissing(Album::class, ['name' => 'Hidden Album']);
}
}

View file

@ -11,6 +11,7 @@ use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Testing\TestResponse;
use ReflectionClass;
use Tests\Traits\CreatesApplication;
@ -31,13 +32,13 @@ abstract class TestCase extends BaseTestCase
TestResponse::macro('log', function (string $file = 'test-response.json'): TestResponse {
/** @var TestResponse $this */
file_put_contents(storage_path('logs/' . $file), $this->getContent());
File::put(storage_path('logs/' . $file), $this->getContent());
return $this;
});
UploadedFile::macro('fromFile', static function (string $path, ?string $name = null): UploadedFile {
return UploadedFile::fake()->createWithContent($name ?? basename($path), file_get_contents($path));
return UploadedFile::fake()->createWithContent($name ?? basename($path), File::get($path));
});
self::createSandbox();

View file

@ -11,9 +11,9 @@ trait SandboxesTests
config(['koel.album_cover_dir' => 'sandbox/img/covers/']);
config(['koel.artist_image_dir' => 'sandbox/img/artists/']);
@mkdir(public_path(config('koel.album_cover_dir')), 0755, true);
@mkdir(public_path(config('koel.artist_image_dir')), 0755, true);
@mkdir(public_path('sandbox/media/'), 0755, true);
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
File::ensureDirectoryExists(public_path('sandbox/media/'));
}
private static function destroySandbox(): void

View file

@ -2,11 +2,12 @@
namespace Tests\Unit\Listeners;
use App\Events\MediaSyncCompleted;
use App\Events\MediaScanCompleted;
use App\Listeners\WriteSyncLog;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use App\Values\ScanResult;
use App\Values\ScanResultCollection;
use Carbon\Carbon;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class WriteSyncLogTest extends TestCase
@ -25,7 +26,7 @@ class WriteSyncLogTest extends TestCase
protected function tearDown(): void
{
@unlink(storage_path('logs/sync-20210102-123456.log'));
File::delete(storage_path('logs/sync-20210102-123456.log'));
config(['koel.sync_log_level' => $this->originalLogLevel]);
parent::tearDown();
@ -55,14 +56,14 @@ class WriteSyncLogTest extends TestCase
);
}
private static function createSyncCompleteEvent(): MediaSyncCompleted
private static function createSyncCompleteEvent(): MediaScanCompleted
{
$resultCollection = SyncResultCollection::create()
->add(SyncResult::success('/media/foo.mp3'))
->add(SyncResult::error('/media/baz.mp3', 'Something went wrong'))
->add(SyncResult::error('/media/qux.mp3', 'Something went horribly wrong'))
->add(SyncResult::skipped('/media/bar.mp3'));
$resultCollection = ScanResultCollection::create()
->add(ScanResult::success('/media/foo.mp3'))
->add(ScanResult::error('/media/baz.mp3', 'Something went wrong'))
->add(ScanResult::error('/media/qux.mp3', 'Something went horribly wrong'))
->add(ScanResult::skipped('/media/bar.mp3'));
return new MediaSyncCompleted($resultCollection);
return new MediaScanCompleted($resultCollection);
}
}

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Models;
use App\Models\Artist;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class ArtistTest extends TestCase
@ -40,7 +41,7 @@ class ArtistTest extends TestCase
public function testArtistsWithNameInUtf16EncodingAreRetrievedCorrectly(): void
{
$name = file_get_contents(__DIR__ . '../../../blobs/utf16');
$name = File::get(__DIR__ . '../../../blobs/utf16');
$artist = Artist::getOrCreate($name);
self::assertTrue(Artist::getOrCreate($name)->is($artist));

View file

@ -7,6 +7,7 @@ use GuzzleHttp\Client as GuzzleHttpClient;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class LastfmClientTest extends TestCase
@ -14,7 +15,7 @@ class LastfmClientTest extends TestCase
public function testGetSessionKey(): void
{
$mock = new MockHandler([
new Response(200, [], file_get_contents(__DIR__ . '/../../../blobs/lastfm/session-key.json')),
new Response(200, [], File::get(__DIR__ . '/../../../blobs/lastfm/session-key.json')),
]);
$client = new LastfmClient(new GuzzleHttpClient(['handler' => HandlerStack::create($mock)]));

View file

@ -8,6 +8,7 @@ use App\Models\Song;
use App\Models\User;
use App\Services\ApiClients\LastfmClient;
use App\Services\LastfmService;
use Illuminate\Support\Facades\File;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
@ -39,7 +40,7 @@ class LastfmServiceTest extends TestCase
$this->client->shouldReceive('get')
->with('?method=artist.getInfo&autocorrect=1&artist=foo&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/artist.json')));
->andReturn(json_decode(File::get(__DIR__ . '/../../blobs/lastfm/artist.json')));
$info = $this->service->getArtistInformation($artist);
@ -61,7 +62,7 @@ class LastfmServiceTest extends TestCase
$this->client->shouldReceive('get')
->with('?method=artist.getInfo&autocorrect=1&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/artist-notfound.json')));
->andReturn(json_decode(File::get(__DIR__ . '/../../blobs/lastfm/artist-notfound.json')));
self::assertNull($this->service->getArtistInformation($artist));
}
@ -74,7 +75,7 @@ class LastfmServiceTest extends TestCase
$this->client->shouldReceive('get')
->with('?method=album.getInfo&autocorrect=1&album=foo&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/album.json')));
->andReturn(json_decode(File::get(__DIR__ . '/../../blobs/lastfm/album.json')));
$info = $this->service->getAlbumInformation($album);
@ -108,7 +109,7 @@ class LastfmServiceTest extends TestCase
$this->client->shouldReceive('get')
->with('?method=album.getInfo&autocorrect=1&album=foo&artist=bar&format=json')
->once()
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/lastfm/album-notfound.json')));
->andReturn(json_decode(File::get(__DIR__ . '/../../blobs/lastfm/album-notfound.json')));
self::assertNull($this->service->getAlbumInformation($album));
}

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Services;
use App\Services\SimpleLrcReader;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Tests\TestCase;
@ -22,9 +23,9 @@ class SimpleLrcReaderTest extends TestCase
$base = sys_get_temp_dir() . '/' . Str::uuid();
$lrcFile = $base . '.lrc';
copy(__DIR__ . '/../../blobs/simple.lrc', $lrcFile);
File::copy(__DIR__ . '/../../blobs/simple.lrc', $lrcFile);
self::assertSame("Line 1\nLine 2\nLine 3", $this->reader->tryReadForMediaFile($base . '.mp3'));
@unlink($lrcFile);
File::delete($lrcFile);
}
}

View file

@ -6,6 +6,7 @@ use App\Models\Album;
use App\Models\Artist;
use App\Services\ApiClients\SpotifyClient;
use App\Services\SpotifyService;
use Illuminate\Support\Facades\File;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
@ -76,7 +77,7 @@ class SpotifyServiceTest extends TestCase
/** @return array<mixed> */
private static function parseFixture(string $name): array
{
return json_decode(file_get_contents(__DIR__ . '/../../blobs/spotify/' . $name), true);
return json_decode(File::get(__DIR__ . '/../../blobs/spotify/' . $name), true);
}
protected function tearDown(): void

View file

@ -7,6 +7,7 @@ use App\Models\Song;
use App\Services\ApiClients\YouTubeClient;
use App\Services\YouTubeService;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
@ -20,7 +21,7 @@ class YouTubeServiceTest extends TestCase
$client->shouldReceive('get')
->with('search?part=snippet&type=video&maxResults=10&pageToken=my-token&q=Foo+Bar')
->andReturn(json_decode(file_get_contents(__DIR__ . '/../../blobs/youtube/search.json')));
->andReturn(json_decode(File::get(__DIR__ . '/../../blobs/youtube/search.json')));
$service = new YouTubeService($client, app(Repository::class));
$response = $service->searchVideosRelatedToSong($song, 'my-token');