mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
Rewrite the file synchronization handling logic
This commit is contained in:
parent
96bbbee4a7
commit
1558062428
13 changed files with 325 additions and 351 deletions
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SettingRepository;
|
||||
use App\Services\MediaCacheService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
@ -22,9 +23,11 @@ class InitCommand extends Command
|
|||
private $dotenvEditor;
|
||||
private $hash;
|
||||
private $db;
|
||||
private $settingRepository;
|
||||
|
||||
public function __construct(
|
||||
MediaCacheService $mediaCacheService,
|
||||
SettingRepository $settingRepository,
|
||||
Artisan $artisan,
|
||||
Hash $hash,
|
||||
DotenvEditor $dotenvEditor,
|
||||
|
@ -37,6 +40,7 @@ class InitCommand extends Command
|
|||
$this->dotenvEditor = $dotenvEditor;
|
||||
$this->hash = $hash;
|
||||
$this->db = $db;
|
||||
$this->settingRepository = $settingRepository;
|
||||
}
|
||||
|
||||
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`.');
|
||||
|
||||
if (Setting::get('media_path')) {
|
||||
if ($this->settingRepository->getMediaPath()) {
|
||||
$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
|
||||
{
|
||||
if (!Setting::get('media_path')) {
|
||||
if ($this->settingRepository->getMediaPath()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Libraries\WatchRecord\InotifyWatchRecord;
|
||||
use App\Models\File;
|
||||
use App\Models\Setting;
|
||||
use App\Repositories\SettingRepository;
|
||||
use App\Services\FileSynchronizer;
|
||||
use App\Services\MediaSyncService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
@ -18,21 +19,22 @@ class SyncMediaCommand extends Command
|
|||
{--force : Force re-syncing even unchanged files}';
|
||||
|
||||
protected $description = 'Sync songs found in configured directory against the database.';
|
||||
|
||||
private $ignored = 0;
|
||||
private $invalid = 0;
|
||||
private $synced = 0;
|
||||
private $mediaSyncService;
|
||||
private $settingRepository;
|
||||
|
||||
/**
|
||||
* @var ProgressBar
|
||||
*/
|
||||
private $progressBar;
|
||||
|
||||
public function __construct(MediaSyncService $mediaSyncService)
|
||||
public function __construct(MediaSyncService $mediaSyncService, SettingRepository $settingRepository)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->mediaSyncService = $mediaSyncService;
|
||||
$this->settingRepository = $settingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -40,19 +42,7 @@ class SyncMediaCommand extends Command
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
if (!Setting::get('media_path')) {
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
$this->ensureMediaPath();
|
||||
|
||||
if (!$record = $this->argument('record')) {
|
||||
$this->syncAll();
|
||||
|
@ -113,23 +103,15 @@ class SyncMediaCommand extends Command
|
|||
{
|
||||
$name = basename($path);
|
||||
|
||||
if ($result === File::SYNC_RESULT_UNMODIFIED) {
|
||||
if ($this->option('verbose')) {
|
||||
$this->line("$name has no changes – ignoring");
|
||||
}
|
||||
|
||||
if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) {
|
||||
$this->ignored++;
|
||||
} elseif ($result === File::SYNC_RESULT_BAD_FILE) {
|
||||
} elseif ($result === FileSynchronizer::SYNC_RESULT_BAD_FILE) {
|
||||
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++;
|
||||
} else {
|
||||
if ($this->option('verbose')) {
|
||||
$this->info("$name synced");
|
||||
}
|
||||
|
||||
$this->synced++;
|
||||
}
|
||||
}
|
||||
|
@ -139,8 +121,28 @@ class SyncMediaCommand extends Command
|
|||
$this->progressBar = $this->getOutput()->createProgressBar($max);
|
||||
}
|
||||
|
||||
public function advanceProgressBar()
|
||||
public function advanceProgressBar(): void
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ use Illuminate\Support\Collection;
|
|||
* @property int album_id
|
||||
* @property int id
|
||||
* @property int artist_id
|
||||
* @property int mtime
|
||||
*/
|
||||
class Song extends Model
|
||||
{
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Song;
|
||||
|
||||
class AlbumRepository extends AbstractRepository
|
||||
{
|
||||
|
@ -10,4 +11,15 @@ class AlbumRepository extends AbstractRepository
|
|||
{
|
||||
return Album::class;
|
||||
}
|
||||
|
||||
public function getNonEmptyAlbumIds(): array
|
||||
{
|
||||
$ids = Song::select('album_id')
|
||||
->groupBy('album_id')
|
||||
->get()
|
||||
->pluck('album_id')
|
||||
->toArray();
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
|
||||
class ArtistRepository extends AbstractRepository
|
||||
{
|
||||
|
@ -10,4 +11,13 @@ class ArtistRepository extends AbstractRepository
|
|||
{
|
||||
return Artist::class;
|
||||
}
|
||||
|
||||
public function getNonEmptyArtistIds(): array
|
||||
{
|
||||
return Song::select('artist_id')
|
||||
->groupBy('artist_id')
|
||||
->get()
|
||||
->pluck('artist_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ namespace App\Services;
|
|||
interface ApiConsumerInterface
|
||||
{
|
||||
public function getEndpoint(): string;
|
||||
|
||||
public function getKey(): ?string;
|
||||
|
||||
public function getSecret(): ?string;
|
||||
}
|
||||
|
|
|
@ -1,129 +1,113 @@
|
|||
<?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\Services\HelperService;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Cache;
|
||||
use Exception;
|
||||
use getID3;
|
||||
use getid3_exception;
|
||||
use getid3_lib;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class File
|
||||
class FileSynchronizer
|
||||
{
|
||||
const SYNC_RESULT_SUCCESS = 1;
|
||||
const SYNC_RESULT_BAD_FILE = 2;
|
||||
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.
|
||||
*
|
||||
* @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(
|
||||
$path,
|
||||
?getID3 $getID3 = null,
|
||||
?MediaMetadataService $mediaMetadataService = null,
|
||||
?HelperService $helperService = null,
|
||||
?SongRepository $songRepository = null
|
||||
) {
|
||||
getID3 $getID3,
|
||||
MediaMetadataService $mediaMetadataService,
|
||||
HelperService $helperService,
|
||||
SongRepository $songRepository,
|
||||
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->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.
|
||||
try {
|
||||
$this->mtime = $this->splFileInfo->getMTime();
|
||||
$this->fileModifiedTime = $this->splFileInfo->getMTime();
|
||||
} catch (Exception $e) {
|
||||
// Not worth logging the error. Just use current stamp for mtime.
|
||||
$this->mtime = time();
|
||||
$this->fileModifiedTime = time();
|
||||
}
|
||||
|
||||
$this->path = $this->splFileInfo->getPathname();
|
||||
$this->hash = $this->helperService->getFileHash($this->path);
|
||||
$this->song = $this->songRepository->getOneById($this->hash);
|
||||
$this->filePath = $this->splFileInfo->getPathname();
|
||||
$this->fileHash = $this->helperService->getFileHash($this->filePath);
|
||||
$this->song = $this->songRepository->getOneById($this->fileHash);
|
||||
$this->syncError = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'])) {
|
||||
$this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found';
|
||||
|
@ -154,14 +138,14 @@ class File
|
|||
'artist' => '',
|
||||
'album' => '',
|
||||
'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'],
|
||||
'track' => (int) $track,
|
||||
'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1),
|
||||
'lyrics' => '',
|
||||
'cover' => array_get($info, 'comments.picture', [null])[0],
|
||||
'path' => $this->path,
|
||||
'mtime' => $this->mtime,
|
||||
'path' => $this->filePath,
|
||||
'mtime' => $this->fileModifiedTime,
|
||||
];
|
||||
|
||||
if (!$comments = array_get($info, 'comments_html')) {
|
||||
|
@ -195,7 +179,6 @@ class File
|
|||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If the file is invalid, don't do anything.
|
||||
if (!$info = $this->getInfo()) {
|
||||
if (!$info = $this->getFileInfo()) {
|
||||
return self::SYNC_RESULT_BAD_FILE;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if ($this->isChanged() || $force) {
|
||||
if ($this->isFileChanged() || $force) {
|
||||
// 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.
|
||||
// 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['album_id'] = $album->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;
|
||||
}
|
||||
|
@ -278,7 +261,7 @@ class File
|
|||
*
|
||||
* @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 ($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.
|
||||
* 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
|
||||
{
|
||||
// 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(
|
||||
Finder::create()
|
||||
$this->finder->create()
|
||||
->depth(0)
|
||||
->ignoreUnreadableDirs()
|
||||
->files()
|
||||
->followLinks()
|
||||
->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;
|
||||
}
|
||||
}
|
|
@ -7,13 +7,13 @@ use App\Events\LibraryChanged;
|
|||
use App\Libraries\WatchRecord\WatchRecordInterface;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\File;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Repositories\SettingRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
use Exception;
|
||||
use getID3;
|
||||
use getid3_exception;
|
||||
use Log;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
@ -42,15 +42,30 @@ class MediaSyncService
|
|||
private $mediaMetadataService;
|
||||
private $songRepository;
|
||||
private $helperService;
|
||||
private $fileSynchronizer;
|
||||
private $finder;
|
||||
private $artistRepository;
|
||||
private $albumRepository;
|
||||
private $settingRepository;
|
||||
|
||||
public function __construct(
|
||||
MediaMetadataService $mediaMetadataService,
|
||||
SongRepository $songRepository,
|
||||
HelperService $helperService
|
||||
ArtistRepository $artistRepository,
|
||||
AlbumRepository $albumRepository,
|
||||
SettingRepository $settingRepository,
|
||||
HelperService $helperService,
|
||||
FileSynchronizer $fileSynchronizer,
|
||||
Finder $finder
|
||||
) {
|
||||
$this->mediaMetadataService = $mediaMetadataService;
|
||||
$this->songRepository = $songRepository;
|
||||
$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,
|
||||
array $tags = [],
|
||||
bool $force = false,
|
||||
SyncMediaCommand $syncCommand = null
|
||||
?SyncMediaCommand $syncCommand = null
|
||||
): 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');
|
||||
}
|
||||
|
||||
$mediaPath = $mediaPath ?: Setting::get('media_path');
|
||||
$this->setSystemRequirements();
|
||||
$this->setTags($tags);
|
||||
|
||||
$results = [
|
||||
|
@ -94,34 +101,33 @@ class MediaSyncService
|
|||
'unmodified' => [],
|
||||
];
|
||||
|
||||
$getID3 = new getID3();
|
||||
$songPaths = $this->gatherFiles($mediaPath);
|
||||
$songPaths = $this->gatherFiles($mediaPath ?: $this->settingRepository->getMediaPath());
|
||||
$syncCommand && $syncCommand->createProgressBar(count($songPaths));
|
||||
|
||||
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)) {
|
||||
case File::SYNC_RESULT_SUCCESS:
|
||||
$results['success'][] = $file;
|
||||
switch ($result) {
|
||||
case FileSynchronizer::SYNC_RESULT_SUCCESS:
|
||||
$results['success'][] = $path;
|
||||
break;
|
||||
case File::SYNC_RESULT_UNMODIFIED:
|
||||
$results['unmodified'][] = $file;
|
||||
case FileSynchronizer::SYNC_RESULT_UNMODIFIED:
|
||||
$results['unmodified'][] = $path;
|
||||
break;
|
||||
default:
|
||||
$results['bad_files'][] = $file;
|
||||
$results['bad_files'][] = $path;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($syncCommand) {
|
||||
$syncCommand->advanceProgressBar();
|
||||
$syncCommand->logSyncStatusToConsole($file->getPath(), $result, $file->getSyncError());
|
||||
$syncCommand->logSyncStatusToConsole($path, $result, $this->fileSynchronizer->getSyncError());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete non-existing songs.
|
||||
$hashes = array_map(function (File $file): string {
|
||||
return $this->helperService->getFileHash($file->getPath());
|
||||
$hashes = array_map(function (string $path): string {
|
||||
return $this->helperService->getFileHash($path);
|
||||
}, array_merge($results['unmodified'], $results['success']));
|
||||
|
||||
Song::deleteWhereIDsNotIn($hashes);
|
||||
|
@ -140,7 +146,7 @@ class MediaSyncService
|
|||
public function gatherFiles(string $path): array
|
||||
{
|
||||
return iterator_to_array(
|
||||
Finder::create()
|
||||
$this->finder->create()
|
||||
->ignoreUnreadableDirs()
|
||||
->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/phanan/koel/issues/450
|
||||
->files()
|
||||
|
@ -173,30 +179,16 @@ class MediaSyncService
|
|||
|
||||
// If the file has been deleted...
|
||||
if ($record->isDeleted()) {
|
||||
// ...and it has a record in our database, remove it.
|
||||
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.");
|
||||
}
|
||||
$this->handleDeletedFileRecord($path);
|
||||
}
|
||||
// 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()) {
|
||||
$result = (new File($path))->sync($this->tags);
|
||||
Log::info($result === File::SYNC_RESULT_SUCCESS ? "Synchronized $path" : "Invalid file $path");
|
||||
|
||||
event(new LibraryChanged());
|
||||
$this->handleNewOrModifiedFileRecord($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a directory's watch record.
|
||||
*
|
||||
* @throws getid3_exception
|
||||
*/
|
||||
private function syncDirectoryRecord(WatchRecordInterface $record): void
|
||||
{
|
||||
|
@ -204,21 +196,9 @@ class MediaSyncService
|
|||
Log::info("'$path' is a directory.");
|
||||
|
||||
if ($record->isDeleted()) {
|
||||
// The directory is removed. We remove all songs in it.
|
||||
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.");
|
||||
}
|
||||
$this->handleDeletedDirectoryRecord($path);
|
||||
} elseif ($record->isNewOrModified()) {
|
||||
foreach ($this->gatherFiles($path) as $file) {
|
||||
(new File($file))->sync($this->tags);
|
||||
}
|
||||
Log::info("Synced all song(s) under $path");
|
||||
|
||||
event(new LibraryChanged());
|
||||
$this->handleNewOrModifiedDirectoryRecord($path);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -254,21 +226,74 @@ class MediaSyncService
|
|||
*/
|
||||
public function tidy(): void
|
||||
{
|
||||
$inUseAlbums = Song::select('album_id')
|
||||
->groupBy('album_id')
|
||||
->get()
|
||||
->pluck('album_id')
|
||||
->toArray();
|
||||
$inUseAlbums = $this->albumRepository->getNonEmptyAlbumIds();
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::deleteWhereIDsNotIn($inUseAlbums);
|
||||
|
||||
$inUseArtists = Song::select('artist_id')
|
||||
->groupBy('artist_id')
|
||||
->get()
|
||||
->pluck('artist_id')
|
||||
->toArray();
|
||||
$inUseArtists = $this->artistRepository->getNonEmptyArtistIds();
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
$inUseArtists[] = Artist::VARIOUS_ID;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ class XAccelRedirectStreamer extends Streamer implements DirectStreamerInterface
|
|||
header('X-Media-Root: '.Setting::get('media_path'));
|
||||
header("X-Accel-Redirect: /media/$relativePath");
|
||||
header("Content-Type: {$this->contentType}");
|
||||
header('Content-Disposition: inline; filename="'.basename($this->song->path).'"');
|
||||
header('Content-Disposition: inline; filename="' . basename($this->song->path) . '"');
|
||||
|
||||
exit;
|
||||
}
|
||||
|
|
|
@ -6,14 +6,12 @@ use App\Events\LibraryChanged;
|
|||
use App\Libraries\WatchRecord\InotifyWatchRecord;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\File;
|
||||
use App\Models\Song;
|
||||
use App\Services\FileSynchronizer;
|
||||
use App\Services\MediaSyncService;
|
||||
use Exception;
|
||||
use getID3;
|
||||
use getid3_exception;
|
||||
use Illuminate\Foundation\Testing\WithoutMiddleware;
|
||||
use Mockery as m;
|
||||
|
||||
class MediaSyncTest extends TestCase
|
||||
{
|
||||
|
@ -25,16 +23,9 @@ class MediaSyncTest extends TestCase
|
|||
public function setUp()
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mediaService = app(MediaSyncService::class);
|
||||
}
|
||||
|
||||
protected function tearDown()
|
||||
{
|
||||
m::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
|
@ -244,14 +235,10 @@ class MediaSyncTest extends TestCase
|
|||
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/back-in-black.mp3']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @throws getid3_exception
|
||||
*/
|
||||
/** @test */
|
||||
public function html_entities_in_tags_are_recognized_and_saved_properly()
|
||||
{
|
||||
$getID3 = m::mock(getID3::class, [
|
||||
$this->mockIocDependency(getID3::class, [
|
||||
'analyze' => [
|
||||
'tags' => [
|
||||
'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']);
|
||||
$this->assertEquals('小岩井こ Random', $info['album']);
|
||||
$this->assertEquals('水谷広実', $info['title']);
|
||||
self::assertEquals('佐倉綾音 Unknown', $info['artist']);
|
||||
self::assertEquals('小岩井こ Random', $info['album']);
|
||||
self::assertEquals('水谷広実', $info['title']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
}
|
56
tests/Integration/Services/FileSynchronizerTest.php
Normal file
56
tests/Integration/Services/FileSynchronizerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue