Rewrite the file synchronization handling logic

This commit is contained in:
Phan An 2018-08-29 16:41:24 +07:00
parent 96bbbee4a7
commit 1558062428
13 changed files with 325 additions and 351 deletions

View file

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Repositories\SettingRepository;
use App\Services\MediaCacheService; use App\Services\MediaCacheService;
use Exception; use Exception;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -22,9 +23,11 @@ class InitCommand extends Command
private $dotenvEditor; private $dotenvEditor;
private $hash; private $hash;
private $db; private $db;
private $settingRepository;
public function __construct( public function __construct(
MediaCacheService $mediaCacheService, MediaCacheService $mediaCacheService,
SettingRepository $settingRepository,
Artisan $artisan, Artisan $artisan,
Hash $hash, Hash $hash,
DotenvEditor $dotenvEditor, DotenvEditor $dotenvEditor,
@ -37,6 +40,7 @@ class InitCommand extends Command
$this->dotenvEditor = $dotenvEditor; $this->dotenvEditor = $dotenvEditor;
$this->hash = $hash; $this->hash = $hash;
$this->db = $db; $this->db = $db;
$this->settingRepository = $settingRepository;
} }
public function handle(): void public function handle(): void
@ -55,7 +59,7 @@ class InitCommand extends Command
$this->comment(PHP_EOL.'🎆 Success! Koel can now be run from localhost with `php artisan serve`.'); $this->comment(PHP_EOL.'🎆 Success! Koel can now be run from localhost with `php artisan serve`.');
if (Setting::get('media_path')) { if ($this->settingRepository->getMediaPath()) {
$this->comment('You can also scan for media with `php artisan koel:sync`.'); $this->comment('You can also scan for media with `php artisan koel:sync`.');
} }
@ -146,7 +150,7 @@ class InitCommand extends Command
private function maybeSetMediaPath(): void private function maybeSetMediaPath(): void
{ {
if (!Setting::get('media_path')) { if ($this->settingRepository->getMediaPath()) {
return; return;
} }

View file

@ -3,8 +3,9 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Libraries\WatchRecord\InotifyWatchRecord; use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\File;
use App\Models\Setting; use App\Models\Setting;
use App\Repositories\SettingRepository;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService; use App\Services\MediaSyncService;
use Exception; use Exception;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -18,21 +19,22 @@ class SyncMediaCommand extends Command
{--force : Force re-syncing even unchanged files}'; {--force : Force re-syncing even unchanged files}';
protected $description = 'Sync songs found in configured directory against the database.'; protected $description = 'Sync songs found in configured directory against the database.';
private $ignored = 0; private $ignored = 0;
private $invalid = 0; private $invalid = 0;
private $synced = 0; private $synced = 0;
private $mediaSyncService; private $mediaSyncService;
private $settingRepository;
/** /**
* @var ProgressBar * @var ProgressBar
*/ */
private $progressBar; private $progressBar;
public function __construct(MediaSyncService $mediaSyncService) public function __construct(MediaSyncService $mediaSyncService, SettingRepository $settingRepository)
{ {
parent::__construct(); parent::__construct();
$this->mediaSyncService = $mediaSyncService; $this->mediaSyncService = $mediaSyncService;
$this->settingRepository = $settingRepository;
} }
/** /**
@ -40,19 +42,7 @@ class SyncMediaCommand extends Command
*/ */
public function handle(): void public function handle(): void
{ {
if (!Setting::get('media_path')) { $this->ensureMediaPath();
$this->warn("Media path hasn't been configured. Let's set it up.");
while (true) {
$path = $this->ask('Absolute path to your media directory:');
if (is_dir($path) && is_readable($path)) {
Setting::set('media_path', $path);
break;
}
$this->error('The path does not exist or not readable. Try again.');
}
}
if (!$record = $this->argument('record')) { if (!$record = $this->argument('record')) {
$this->syncAll(); $this->syncAll();
@ -113,23 +103,15 @@ class SyncMediaCommand extends Command
{ {
$name = basename($path); $name = basename($path);
if ($result === File::SYNC_RESULT_UNMODIFIED) { if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) {
if ($this->option('verbose')) {
$this->line("$name has no changes  ignoring");
}
$this->ignored++; $this->ignored++;
} elseif ($result === File::SYNC_RESULT_BAD_FILE) { } elseif ($result === FileSynchronizer::SYNC_RESULT_BAD_FILE) {
if ($this->option('verbose')) { if ($this->option('verbose')) {
$this->error("$name is not a valid media file because: ".$reason); $this->error(PHP_EOL . "'$name' is not a valid media file: ".$reason);
} }
$this->invalid++; $this->invalid++;
} else { } else {
if ($this->option('verbose')) {
$this->info("$name synced");
}
$this->synced++; $this->synced++;
} }
} }
@ -139,8 +121,28 @@ class SyncMediaCommand extends Command
$this->progressBar = $this->getOutput()->createProgressBar($max); $this->progressBar = $this->getOutput()->createProgressBar($max);
} }
public function advanceProgressBar() public function advanceProgressBar(): void
{ {
$this->progressBar->advance(); $this->progressBar->advance();
} }
private function ensureMediaPath(): void
{
if ($this->settingRepository->getMediaPath()) {
return;
}
$this->warn("Media path hasn't been configured. Let's set it up.");
while (true) {
$path = $this->ask('Absolute path to your media directory:');
if (is_dir($path) && is_readable($path)) {
Setting::set('media_path', $path);
break;
}
$this->error('The path does not exist or is not readable. Try again.');
}
}
} }

View file

@ -23,6 +23,7 @@ use Illuminate\Support\Collection;
* @property int album_id * @property int album_id
* @property int id * @property int id
* @property int artist_id * @property int artist_id
* @property int mtime
*/ */
class Song extends Model class Song extends Model
{ {

View file

@ -3,6 +3,7 @@
namespace App\Repositories; namespace App\Repositories;
use App\Models\Album; use App\Models\Album;
use App\Models\Song;
class AlbumRepository extends AbstractRepository class AlbumRepository extends AbstractRepository
{ {
@ -10,4 +11,15 @@ class AlbumRepository extends AbstractRepository
{ {
return Album::class; return Album::class;
} }
public function getNonEmptyAlbumIds(): array
{
$ids = Song::select('album_id')
->groupBy('album_id')
->get()
->pluck('album_id')
->toArray();
return $ids;
}
} }

View file

@ -3,6 +3,7 @@
namespace App\Repositories; namespace App\Repositories;
use App\Models\Artist; use App\Models\Artist;
use App\Models\Song;
class ArtistRepository extends AbstractRepository class ArtistRepository extends AbstractRepository
{ {
@ -10,4 +11,13 @@ class ArtistRepository extends AbstractRepository
{ {
return Artist::class; return Artist::class;
} }
public function getNonEmptyArtistIds(): array
{
return Song::select('artist_id')
->groupBy('artist_id')
->get()
->pluck('artist_id')
->toArray();
}
} }

View file

@ -5,8 +5,6 @@ namespace App\Services;
interface ApiConsumerInterface interface ApiConsumerInterface
{ {
public function getEndpoint(): string; public function getEndpoint(): string;
public function getKey(): ?string; public function getKey(): ?string;
public function getSecret(): ?string; public function getSecret(): ?string;
} }

View file

@ -1,129 +1,113 @@
<?php <?php
namespace App\Models; namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;
use App\Services\HelperService;
use App\Services\MediaMetadataService;
use Cache;
use Exception; use Exception;
use getID3; use getID3;
use getid3_exception;
use getid3_lib; use getid3_lib;
use InvalidArgumentException; use InvalidArgumentException;
use Illuminate\Contracts\Cache\Repository as Cache;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
class File class FileSynchronizer
{ {
const SYNC_RESULT_SUCCESS = 1; const SYNC_RESULT_SUCCESS = 1;
const SYNC_RESULT_BAD_FILE = 2; const SYNC_RESULT_BAD_FILE = 2;
const SYNC_RESULT_UNMODIFIED = 3; const SYNC_RESULT_UNMODIFIED = 3;
private $getID3;
private $mediaMetadataService;
private $helperService;
private $songRepository;
private $cache;
private $finder;
/** /**
* A MD5 hash of the file's path. * @var SplFileInfo
*/
private $splFileInfo;
/**
* @var int
*/
private $fileModifiedTime;
/**
* @var string
*/
private $filePath;
/**
* A (MD5) hash of the file's path.
* This value is unique, and can be used to query a Song record. * This value is unique, and can be used to query a Song record.
* *
* @var string * @var string
*/ */
protected $hash; private $fileHash;
/** /**
* The file's last modified time. * The song model that's associated with the current file.
* *
* @var int * @var Song|null
*/ */
protected $mtime; private $song;
/** /**
* The file's path. * @var string|null
*/ */
protected $path; private $syncError;
/**
* The getID3 object, for ID3 tag reading.
*/
protected $getID3;
/**
* @var MediaMetadataService
*/
private $mediaMetadataService;
/**
* The SplFileInfo object of the file.
*
* @var SplFileInfo
*/
protected $splFileInfo;
/**
* The song model that's associated with this file.
*
* @var Song
*/
protected $song;
/**
* The last parsing error text, if any.
*
* @var string
*/
protected $syncError;
/**
* @var HelperService
*/
private $helperService;
/**
* @var SongRepository
*/
private $songRepository;
/**
* Construct our File object.
* Upon construction, we'll set the path, hash, and associated Song object (if any).
*
* @param string|SplFileInfo $path Either the file's path, or a SplFileInfo object
*
* @throws getid3_exception
*
* @todo Refactor this bloated, anti-pattern monster.
*/
public function __construct( public function __construct(
$path, getID3 $getID3,
?getID3 $getID3 = null, MediaMetadataService $mediaMetadataService,
?MediaMetadataService $mediaMetadataService = null, HelperService $helperService,
?HelperService $helperService = null, SongRepository $songRepository,
?SongRepository $songRepository = null Cache $cache,
) { Finder $finder
)
{
$this->getID3 = $getID3;
$this->mediaMetadataService = $mediaMetadataService;
$this->helperService = $helperService;
$this->songRepository = $songRepository;
$this->cache = $cache;
$this->finder = $finder;
}
/**
* @param string|SplFileInfo $path
*/
public function setFile($path): self
{
$this->splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path); $this->splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
$this->setGetID3($getID3);
$this->setMediaMetadataService($mediaMetadataService);
$this->setHelperService($helperService);
$this->setSongRepository($songRepository);
// Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows. // Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows.
try { try {
$this->mtime = $this->splFileInfo->getMTime(); $this->fileModifiedTime = $this->splFileInfo->getMTime();
} catch (Exception $e) { } catch (Exception $e) {
// Not worth logging the error. Just use current stamp for mtime. // Not worth logging the error. Just use current stamp for mtime.
$this->mtime = time(); $this->fileModifiedTime = time();
} }
$this->path = $this->splFileInfo->getPathname(); $this->filePath = $this->splFileInfo->getPathname();
$this->hash = $this->helperService->getFileHash($this->path); $this->fileHash = $this->helperService->getFileHash($this->filePath);
$this->song = $this->songRepository->getOneById($this->hash); $this->song = $this->songRepository->getOneById($this->fileHash);
$this->syncError = null; $this->syncError = null;
return $this;
} }
/** /**
* Get all applicable ID3 info from the file. * Get all applicable ID3 info from the file.
*/ */
public function getInfo(): array public function getFileInfo(): array
{ {
$info = $this->getID3->analyze($this->path); $info = $this->getID3->analyze($this->filePath);
if (isset($info['error']) || !isset($info['playtime_seconds'])) { if (isset($info['error']) || !isset($info['playtime_seconds'])) {
$this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found'; $this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found';
@ -154,14 +138,14 @@ class File
'artist' => '', 'artist' => '',
'album' => '', 'album' => '',
'compilation' => false, 'compilation' => false,
'title' => basename($this->path, '.'.pathinfo($this->path, PATHINFO_EXTENSION)), // default to be file name 'title' => basename($this->filePath, '.'.pathinfo($this->filePath, PATHINFO_EXTENSION)), // default to be file name
'length' => $info['playtime_seconds'], 'length' => $info['playtime_seconds'],
'track' => (int) $track, 'track' => (int) $track,
'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1), 'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1),
'lyrics' => '', 'lyrics' => '',
'cover' => array_get($info, 'comments.picture', [null])[0], 'cover' => array_get($info, 'comments.picture', [null])[0],
'path' => $this->path, 'path' => $this->filePath,
'mtime' => $this->mtime, 'mtime' => $this->fileModifiedTime,
]; ];
if (!$comments = array_get($info, 'comments_html')) { if (!$comments = array_get($info, 'comments_html')) {
@ -195,7 +179,6 @@ class File
return $props; return $props;
} }
/** /**
* Sync the song with all available media info against the database. * Sync the song with all available media info against the database.
* *
@ -209,21 +192,21 @@ class File
public function sync(array $tags, bool $force = false) public function sync(array $tags, bool $force = false)
{ {
// If the file is not new or changed and we're not forcing update, don't do anything. // If the file is not new or changed and we're not forcing update, don't do anything.
if (!$this->isNewOrChanged() && !$force) { if (!$this->isFileNewOrChanged() && !$force) {
return self::SYNC_RESULT_UNMODIFIED; return self::SYNC_RESULT_UNMODIFIED;
} }
// If the file is invalid, don't do anything. // If the file is invalid, don't do anything.
if (!$info = $this->getInfo()) { if (!$info = $this->getFileInfo()) {
return self::SYNC_RESULT_BAD_FILE; return self::SYNC_RESULT_BAD_FILE;
} }
// Fixes #366. If the file is new, we use all tags by simply setting $force to false. // Fixes #366. If the file is new, we use all tags by simply setting $force to false.
if ($this->isNew()) { if ($this->isFileNew()) {
$force = false; $force = false;
} }
if ($this->isChanged() || $force) { if ($this->isFileChanged() || $force) {
// This is a changed file, or the user is forcing updates. // This is a changed file, or the user is forcing updates.
// In such a case, the user must have specified a list of tags to sync. // In such a case, the user must have specified a list of tags to sync.
// A sample command could be: ./artisan koel:sync --force --tags=artist,album,lyrics // A sample command could be: ./artisan koel:sync --force --tags=artist,album,lyrics
@ -268,7 +251,7 @@ class File
$data = array_except($info, ['artist', 'albumartist', 'album', 'cover', 'compilation']); $data = array_except($info, ['artist', 'albumartist', 'album', 'cover', 'compilation']);
$data['album_id'] = $album->id; $data['album_id'] = $album->id;
$data['artist_id'] = $artist->id; $data['artist_id'] = $artist->id;
$this->song = Song::updateOrCreate(['id' => $this->hash], $data); $this->song = Song::updateOrCreate(['id' => $this->fileHash], $data);
return self::SYNC_RESULT_SUCCESS; return self::SYNC_RESULT_SUCCESS;
} }
@ -278,7 +261,7 @@ class File
* *
* @param mixed[]|null $coverData * @param mixed[]|null $coverData
*/ */
private function generateAlbumCover(Album $album, ?array $coverData): void private function generateAlbumCover(Album $album, ?array $coverData)
{ {
// If the album has no cover, we try to get the cover image from existing tag data // If the album has no cover, we try to get the cover image from existing tag data
if ($coverData) { if ($coverData) {
@ -296,56 +279,6 @@ class File
} }
} }
/**
* Determine if the file is new (its Song record can't be found in the database).
*/
public function isNew(): bool
{
return !$this->song;
}
/**
* Determine if the file is changed (its Song record is found, but the timestamp is different).
*/
public function isChanged(): bool
{
return !$this->isNew() && $this->song->mtime !== $this->mtime;
}
/**
* Determine if the file is new or changed.
*/
public function isNewOrChanged(): bool
{
return $this->isNew() || $this->isChanged();
}
public function getGetID3(): getID3
{
return $this->getID3;
}
/**
* Get the last parsing error's text.
*/
public function getSyncError(): ?string
{
return $this->syncError;
}
/**
* @throws getid3_exception
*/
public function setGetID3(?getID3 $getID3 = null): void
{
$this->getID3 = $getID3 ?: new getID3();
}
public function getPath(): string
{
return $this->path;
}
/** /**
* Issue #380. * Issue #380.
* Some albums have its own cover image under the same directory as cover|folder.jpg/png. * Some albums have its own cover image under the same directory as cover|folder.jpg/png.
@ -356,15 +289,15 @@ class File
private function getCoverFileUnderSameDirectory(): ?string private function getCoverFileUnderSameDirectory(): ?string
{ {
// As directory scanning can be expensive, we cache and reuse the result. // As directory scanning can be expensive, we cache and reuse the result.
return Cache::remember(md5($this->path.'_cover'), 24 * 60, function () { return $this->cache->remember(md5($this->filePath . '_cover'), 24 * 60, function (): ?string {
$matches = array_keys(iterator_to_array( $matches = array_keys(iterator_to_array(
Finder::create() $this->finder->create()
->depth(0) ->depth(0)
->ignoreUnreadableDirs() ->ignoreUnreadableDirs()
->files() ->files()
->followLinks() ->followLinks()
->name('/(cov|fold)er\.(jpe?g|png)$/i') ->name('/(cov|fold)er\.(jpe?g|png)$/i')
->in(dirname($this->path)) ->in(dirname($this->filePath))
) )
); );
@ -379,18 +312,30 @@ class File
}); });
} }
private function setMediaMetadataService(?MediaMetadataService $mediaMetadataService = null): void
/**
* Determine if the file is new (its Song record can't be found in the database).
*/
public function isFileNew(): bool
{ {
$this->mediaMetadataService = $mediaMetadataService ?: app(MediaMetadataService::class); return !$this->song;
} }
private function setHelperService(?HelperService $helperService = null): void /**
* Determine if the file is changed (its Song record is found, but the timestamp is different).
*/
public function isFileChanged(): bool
{ {
$this->helperService = $helperService ?: app(HelperService::class); return !$this->isFileNew() && $this->song->mtime !== $this->fileModifiedTime;
} }
public function setSongRepository(?SongRepository $songRepository = null): void public function isFileNewOrChanged(): bool
{ {
$this->songRepository = $songRepository ?: app(SongRepository::class); return $this->isFileNew() || $this->isFileChanged();
}
public function getSyncError(): ?string
{
return $this->syncError;
} }
} }

View file

@ -7,13 +7,13 @@ use App\Events\LibraryChanged;
use App\Libraries\WatchRecord\WatchRecordInterface; use App\Libraries\WatchRecord\WatchRecordInterface;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;
use App\Models\File;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Song; use App\Models\Song;
use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository;
use App\Repositories\SettingRepository;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;
use Exception; use Exception;
use getID3;
use getid3_exception;
use Log; use Log;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
@ -42,15 +42,30 @@ class MediaSyncService
private $mediaMetadataService; private $mediaMetadataService;
private $songRepository; private $songRepository;
private $helperService; private $helperService;
private $fileSynchronizer;
private $finder;
private $artistRepository;
private $albumRepository;
private $settingRepository;
public function __construct( public function __construct(
MediaMetadataService $mediaMetadataService, MediaMetadataService $mediaMetadataService,
SongRepository $songRepository, SongRepository $songRepository,
HelperService $helperService ArtistRepository $artistRepository,
AlbumRepository $albumRepository,
SettingRepository $settingRepository,
HelperService $helperService,
FileSynchronizer $fileSynchronizer,
Finder $finder
) { ) {
$this->mediaMetadataService = $mediaMetadataService; $this->mediaMetadataService = $mediaMetadataService;
$this->songRepository = $songRepository; $this->songRepository = $songRepository;
$this->helperService = $helperService; $this->helperService = $helperService;
$this->fileSynchronizer = $fileSynchronizer;
$this->finder = $finder;
$this->artistRepository = $artistRepository;
$this->albumRepository = $albumRepository;
$this->settingRepository = $settingRepository;
} }
/** /**
@ -75,17 +90,9 @@ class MediaSyncService
?string $mediaPath = null, ?string $mediaPath = null,
array $tags = [], array $tags = [],
bool $force = false, bool $force = false,
SyncMediaCommand $syncCommand = null ?SyncMediaCommand $syncCommand = null
): void { ): void {
if (!app()->runningInConsole()) { $this->setSystemRequirements();
set_time_limit(config('koel.sync.timeout'));
}
if (config('koel.memory_limit')) {
ini_set('memory_limit', config('koel.memory_limit').'M');
}
$mediaPath = $mediaPath ?: Setting::get('media_path');
$this->setTags($tags); $this->setTags($tags);
$results = [ $results = [
@ -94,34 +101,33 @@ class MediaSyncService
'unmodified' => [], 'unmodified' => [],
]; ];
$getID3 = new getID3(); $songPaths = $this->gatherFiles($mediaPath ?: $this->settingRepository->getMediaPath());
$songPaths = $this->gatherFiles($mediaPath);
$syncCommand && $syncCommand->createProgressBar(count($songPaths)); $syncCommand && $syncCommand->createProgressBar(count($songPaths));
foreach ($songPaths as $path) { foreach ($songPaths as $path) {
$file = new File($path, $getID3, $this->mediaMetadataService); $result = $this->fileSynchronizer->setFile($path)->sync($this->tags, $force);
switch ($result = $file->sync($this->tags, $force)) { switch ($result) {
case File::SYNC_RESULT_SUCCESS: case FileSynchronizer::SYNC_RESULT_SUCCESS:
$results['success'][] = $file; $results['success'][] = $path;
break; break;
case File::SYNC_RESULT_UNMODIFIED: case FileSynchronizer::SYNC_RESULT_UNMODIFIED:
$results['unmodified'][] = $file; $results['unmodified'][] = $path;
break; break;
default: default:
$results['bad_files'][] = $file; $results['bad_files'][] = $path;
break; break;
} }
if ($syncCommand) { if ($syncCommand) {
$syncCommand->advanceProgressBar(); $syncCommand->advanceProgressBar();
$syncCommand->logSyncStatusToConsole($file->getPath(), $result, $file->getSyncError()); $syncCommand->logSyncStatusToConsole($path, $result, $this->fileSynchronizer->getSyncError());
} }
} }
// Delete non-existing songs. // Delete non-existing songs.
$hashes = array_map(function (File $file): string { $hashes = array_map(function (string $path): string {
return $this->helperService->getFileHash($file->getPath()); return $this->helperService->getFileHash($path);
}, array_merge($results['unmodified'], $results['success'])); }, array_merge($results['unmodified'], $results['success']));
Song::deleteWhereIDsNotIn($hashes); Song::deleteWhereIDsNotIn($hashes);
@ -140,7 +146,7 @@ class MediaSyncService
public function gatherFiles(string $path): array public function gatherFiles(string $path): array
{ {
return iterator_to_array( return iterator_to_array(
Finder::create() $this->finder->create()
->ignoreUnreadableDirs() ->ignoreUnreadableDirs()
->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/phanan/koel/issues/450 ->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/phanan/koel/issues/450
->files() ->files()
@ -173,30 +179,16 @@ class MediaSyncService
// If the file has been deleted... // If the file has been deleted...
if ($record->isDeleted()) { if ($record->isDeleted()) {
// ...and it has a record in our database, remove it. $this->handleDeletedFileRecord($path);
if ($song = $this->songRepository->getOneByPath($path)) {
$song->delete();
Log::info("$path deleted.");
event(new LibraryChanged());
} else {
Log::info("$path doesn't exist in our database--skipping.");
}
} }
// Otherwise, it's a new or changed file. Try to sync it in. // Otherwise, it's a new or changed file. Try to sync it in.
// File format etc. will be handled by File::sync().
elseif ($record->isNewOrModified()) { elseif ($record->isNewOrModified()) {
$result = (new File($path))->sync($this->tags); $this->handleNewOrModifiedFileRecord($path);
Log::info($result === File::SYNC_RESULT_SUCCESS ? "Synchronized $path" : "Invalid file $path");
event(new LibraryChanged());
} }
} }
/** /**
* Sync a directory's watch record. * Sync a directory's watch record.
*
* @throws getid3_exception
*/ */
private function syncDirectoryRecord(WatchRecordInterface $record): void private function syncDirectoryRecord(WatchRecordInterface $record): void
{ {
@ -204,21 +196,9 @@ class MediaSyncService
Log::info("'$path' is a directory."); Log::info("'$path' is a directory.");
if ($record->isDeleted()) { if ($record->isDeleted()) {
// The directory is removed. We remove all songs in it. $this->handleDeletedDirectoryRecord($path);
if ($count = Song::inDirectory($path)->delete()) {
Log::info("Deleted $count song(s) under $path");
event(new LibraryChanged());
} else {
Log::info("$path is empty--no action needed.");
}
} elseif ($record->isNewOrModified()) { } elseif ($record->isNewOrModified()) {
foreach ($this->gatherFiles($path) as $file) { $this->handleNewOrModifiedDirectoryRecord($path);
(new File($file))->sync($this->tags);
}
Log::info("Synced all song(s) under $path");
event(new LibraryChanged());
} }
} }
@ -239,14 +219,6 @@ class MediaSyncService
} }
} }
/**
* Generate a unique hash for a file path.
*/
public function getFileHash(string $path): string
{
return File::getHash($path);
}
/** /**
* Tidy up the library by deleting empty albums and artists. * Tidy up the library by deleting empty albums and artists.
* *
@ -254,21 +226,74 @@ class MediaSyncService
*/ */
public function tidy(): void public function tidy(): void
{ {
$inUseAlbums = Song::select('album_id') $inUseAlbums = $this->albumRepository->getNonEmptyAlbumIds();
->groupBy('album_id')
->get()
->pluck('album_id')
->toArray();
$inUseAlbums[] = Album::UNKNOWN_ID; $inUseAlbums[] = Album::UNKNOWN_ID;
Album::deleteWhereIDsNotIn($inUseAlbums); Album::deleteWhereIDsNotIn($inUseAlbums);
$inUseArtists = Song::select('artist_id') $inUseArtists = $this->artistRepository->getNonEmptyArtistIds();
->groupBy('artist_id')
->get()
->pluck('artist_id')
->toArray();
$inUseArtists[] = Artist::UNKNOWN_ID; $inUseArtists[] = Artist::UNKNOWN_ID;
$inUseArtists[] = Artist::VARIOUS_ID; $inUseArtists[] = Artist::VARIOUS_ID;
Artist::deleteWhereIDsNotIn(array_filter($inUseArtists)); Artist::deleteWhereIDsNotIn(array_filter($inUseArtists));
} }
private function setSystemRequirements(): void
{
if (!app()->runningInConsole()) {
set_time_limit(config('koel.sync.timeout'));
}
if (config('koel.memory_limit')) {
ini_set('memory_limit', config('koel.memory_limit') . 'M');
}
}
/**
* @throws Exception
*/
private function handleDeletedFileRecord(string $path): void
{
if ($song = $this->songRepository->getOneByPath($path)) {
$song->delete();
Log::info("$path deleted.");
event(new LibraryChanged());
} else {
Log::info("$path doesn't exist in our database--skipping.");
}
}
private function handleNewOrModifiedFileRecord(string $path): void
{
$result = $this->fileSynchronizer->setFile($path)->sync($this->tags);
if ($result === FileSynchronizer::SYNC_RESULT_SUCCESS) {
Log::info("Synchronized $path");
} else {
Log::info("Failed to synchronized $path. Maybe an invalid file?");
}
event(new LibraryChanged());
}
private function handleDeletedDirectoryRecord(string $path): void
{
if ($count = Song::inDirectory($path)->delete()) {
Log::info("Deleted $count song(s) under $path");
event(new LibraryChanged());
} else {
Log::info("$path is empty--no action needed.");
}
}
private function handleNewOrModifiedDirectoryRecord(string $path): void
{
foreach ($this->gatherFiles($path) as $file) {
$this->fileSynchronizer->setFile($file)->sync($this->tags);
}
Log::info("Synced all song(s) under $path");
event(new LibraryChanged());
}
} }

View file

@ -19,7 +19,7 @@ class XAccelRedirectStreamer extends Streamer implements DirectStreamerInterface
header('X-Media-Root: '.Setting::get('media_path')); header('X-Media-Root: '.Setting::get('media_path'));
header("X-Accel-Redirect: /media/$relativePath"); header("X-Accel-Redirect: /media/$relativePath");
header("Content-Type: {$this->contentType}"); header("Content-Type: {$this->contentType}");
header('Content-Disposition: inline; filename="'.basename($this->song->path).'"'); header('Content-Disposition: inline; filename="' . basename($this->song->path) . '"');
exit; exit;
} }

View file

@ -6,14 +6,12 @@ use App\Events\LibraryChanged;
use App\Libraries\WatchRecord\InotifyWatchRecord; use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;
use App\Models\File;
use App\Models\Song; use App\Models\Song;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService; use App\Services\MediaSyncService;
use Exception; use Exception;
use getID3; use getID3;
use getid3_exception;
use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Foundation\Testing\WithoutMiddleware;
use Mockery as m;
class MediaSyncTest extends TestCase class MediaSyncTest extends TestCase
{ {
@ -25,16 +23,9 @@ class MediaSyncTest extends TestCase
public function setUp() public function setUp()
{ {
parent::setUp(); parent::setUp();
$this->mediaService = app(MediaSyncService::class); $this->mediaService = app(MediaSyncService::class);
} }
protected function tearDown()
{
m::close();
parent::tearDown();
}
/** /**
* @test * @test
* *
@ -244,14 +235,10 @@ class MediaSyncTest extends TestCase
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/back-in-black.mp3']); $this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/back-in-black.mp3']);
} }
/** /** @test */
* @test
*
* @throws getid3_exception
*/
public function html_entities_in_tags_are_recognized_and_saved_properly() public function html_entities_in_tags_are_recognized_and_saved_properly()
{ {
$getID3 = m::mock(getID3::class, [ $this->mockIocDependency(getID3::class, [
'analyze' => [ 'analyze' => [
'tags' => [ 'tags' => [
'id3v2' => [ 'id3v2' => [
@ -265,11 +252,13 @@ class MediaSyncTest extends TestCase
], ],
]); ]);
$info = (new File(__DIR__.'/songs/blank.mp3', $getID3))->getInfo(); /** @var FileSynchronizer $fileSynchronizer */
$fileSynchronizer = app(FileSynchronizer::class);
$info = $fileSynchronizer->setFile(__DIR__.'/songs/blank.mp3')->getFileInfo();
$this->assertEquals('佐倉綾音 Unknown', $info['artist']); self::assertEquals('佐倉綾音 Unknown', $info['artist']);
$this->assertEquals('小岩井こ Random', $info['album']); self::assertEquals('小岩井こ Random', $info['album']);
$this->assertEquals('水谷広実', $info['title']); self::assertEquals('水谷広実', $info['title']);
} }
/** /**

View file

@ -1,49 +0,0 @@
<?php
namespace Tests\Integration\Models;
use App\Models\File;
use Tests\TestCase;
class FileTest extends TestCase
{
/** @test */
public function file_info_is_retrieved_correctly()
{
$file = new File(__DIR__.'/../../songs/full.mp3');
$info = $file->getInfo();
$expectedData = [
'artist' => 'Koel',
'album' => 'Koel Testing Vol. 1',
'compilation' => false,
'title' => 'Amet',
'track' => 5,
'disc' => 3,
'lyrics' => "Foo\rbar",
'cover' => [
'data' => file_get_contents(__DIR__.'/../../blobs/cover.png'),
'image_mime' => 'image/png',
'image_width' => 512,
'image_height' => 512,
'imagetype' => 'PNG',
'picturetype' => 'Other',
'description' => '',
'datalength' => 7627,
],
'path' => __DIR__.'/../../songs/full.mp3',
'mtime' => filemtime(__DIR__.'/../../songs/full.mp3'),
'albumartist' => '',
];
$this->assertArraySubset($expectedData, $info);
$this->assertEquals(10.083, $info['length'], '', 0.001);
}
/** @test */
public function song_without_a_title_tag_has_file_name_as_the_title()
{
$file = new File(__DIR__.'/../../songs/blank.mp3');
$this->assertSame('blank', $file->getInfo()['title']);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Tests\Integration\Services;
use App\Services\FileSynchronizer;
use Tests\TestCase;
class FileSynchronizerTest extends TestCase
{
/** @var FileSynchronizer */
private $fileSynchronizer;
public function setUp()
{
parent::setUp();
$this->fileSynchronizer = app(FileSynchronizer::class);
}
public function testGetFileInfo()
{
$info = $this->fileSynchronizer->setFile(__DIR__ . '/../../songs/full.mp3')->getFileInfo();
$expectedData = [
'artist' => 'Koel',
'album' => 'Koel Testing Vol. 1',
'compilation' => false,
'title' => 'Amet',
'track' => 5,
'disc' => 3,
'lyrics' => "Foo\rbar",
'cover' => [
'data' => file_get_contents(__DIR__ . '/../../blobs/cover.png'),
'image_mime' => 'image/png',
'image_width' => 512,
'image_height' => 512,
'imagetype' => 'PNG',
'picturetype' => 'Other',
'description' => '',
'datalength' => 7627,
],
'path' => __DIR__ . '/../../songs/full.mp3',
'mtime' => filemtime(__DIR__ . '/../../songs/full.mp3'),
'albumartist' => '',
];
self::assertArraySubset($expectedData, $info);
self::assertEquals(10.083, $info['length'], '', 0.001);
}
/** @test */
public function song_without_a_title_tag_has_file_name_as_the_title()
{
$this->fileSynchronizer->setFile(__DIR__ . '/../../songs/blank.mp3');
self::assertSame('blank', $this->fileSynchronizer->getFileInfo()['title']);
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Tests\Unit\Models;
use App\Models\File;
use SplFileInfo;
use Tests\TestCase;
class FileTest extends TestCase
{
/** @test */
public function it_can_be_instantiated()
{
$file = new File(__DIR__.'/../songs/full.mp3');
$this->assertInstanceOf(File::class, $file);
$file = new File(new SplFileInfo(__DIR__.'/../songs/full.mp3'));
$this->assertInstanceOf(File::class, $file);
}
}