2016-03-22 08:22:39 +00:00
|
|
|
<?php
|
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
namespace App\Services;
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
use App\Models\Album;
|
|
|
|
use App\Models\Artist;
|
|
|
|
use App\Models\Song;
|
2018-08-29 06:15:11 +00:00
|
|
|
use App\Repositories\SongRepository;
|
2022-07-05 13:47:26 +00:00
|
|
|
use App\Values\SongScanInformation;
|
2022-07-29 10:51:20 +00:00
|
|
|
use App\Values\SyncResult;
|
2019-10-09 17:36:22 +00:00
|
|
|
use getID3;
|
2018-08-29 09:41:24 +00:00
|
|
|
use Illuminate\Contracts\Cache\Repository as Cache;
|
2022-07-05 13:47:26 +00:00
|
|
|
use Illuminate\Support\Arr;
|
2016-03-22 08:22:39 +00:00
|
|
|
use SplFileInfo;
|
2016-08-07 10:31:19 +00:00
|
|
|
use Symfony\Component\Finder\Finder;
|
2020-12-22 20:11:22 +00:00
|
|
|
use Throwable;
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
class FileSynchronizer
|
2016-03-22 08:22:39 +00:00
|
|
|
{
|
2021-06-05 10:47:56 +00:00
|
|
|
private ?int $fileModifiedTime = null;
|
|
|
|
private ?string $filePath = null;
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2016-08-17 14:48:18 +00:00
|
|
|
/**
|
2018-08-29 09:41:24 +00:00
|
|
|
* The song model that's associated with the current file.
|
2016-08-17 14:48:18 +00:00
|
|
|
*/
|
2021-06-05 10:47:56 +00:00
|
|
|
private ?Song $song;
|
2016-08-17 14:48:18 +00:00
|
|
|
|
2022-07-29 11:08:24 +00:00
|
|
|
private ?string $syncError = null;
|
2018-08-29 06:15:11 +00:00
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
public function __construct(
|
2022-07-05 13:47:26 +00:00
|
|
|
private getID3 $getID3,
|
|
|
|
private MediaMetadataService $mediaMetadataService,
|
|
|
|
private SongRepository $songRepository,
|
|
|
|
private Cache $cache,
|
|
|
|
private Finder $finder
|
2019-06-30 11:13:41 +00:00
|
|
|
) {
|
2018-08-29 09:41:24 +00:00
|
|
|
}
|
2017-06-03 23:21:50 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
public function setFile(string|SplFileInfo $path): self
|
2018-08-29 09:41:24 +00:00
|
|
|
{
|
2022-07-07 10:45:47 +00:00
|
|
|
$file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
|
2016-08-11 03:25:17 +00:00
|
|
|
|
2022-08-01 10:42:33 +00:00
|
|
|
$this->filePath = $file->getRealPath();
|
|
|
|
$this->song = $this->songRepository->getOneByPath($this->filePath);
|
2022-07-07 10:45:47 +00:00
|
|
|
$this->fileModifiedTime = Helper::getModifiedTime($file);
|
2018-08-29 09:41:24 +00:00
|
|
|
|
|
|
|
return $this;
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
|
|
|
|
2022-07-07 10:45:47 +00:00
|
|
|
public function getFileScanInformation(): ?SongScanInformation
|
2016-03-22 08:22:39 +00:00
|
|
|
{
|
2018-08-29 09:41:24 +00:00
|
|
|
$info = $this->getID3->analyze($this->filePath);
|
2022-07-07 10:45:47 +00:00
|
|
|
$this->syncError = Arr::get($info, 'error.0') ?: (Arr::get($info, 'playtime_seconds') ? null : 'Empty file');
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2022-07-07 10:45:47 +00:00
|
|
|
return $this->syncError ? null : SongScanInformation::fromGetId3Info($info);
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
2018-08-29 09:42:11 +00:00
|
|
|
|
2016-03-22 08:22:39 +00:00
|
|
|
/**
|
2022-07-05 13:47:26 +00:00
|
|
|
* Sync the song with all available media info into the database.
|
2016-03-22 08:22:39 +00:00
|
|
|
*
|
2022-07-07 10:45:47 +00:00
|
|
|
* @param array<string> $ignores The tags to ignore/exclude (only taken into account if the song already exists)
|
2020-12-22 20:11:22 +00:00
|
|
|
* @param bool $force Whether to force syncing, even if the file is unchanged
|
2016-03-22 08:22:39 +00:00
|
|
|
*/
|
2022-07-29 10:51:20 +00:00
|
|
|
public function sync(array $ignores = [], bool $force = false): SyncResult
|
2016-03-22 08:22:39 +00:00
|
|
|
{
|
2018-08-29 09:41:24 +00:00
|
|
|
if (!$this->isFileNewOrChanged() && !$force) {
|
2022-07-29 10:51:20 +00:00
|
|
|
return SyncResult::skipped($this->filePath);
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
|
|
|
|
2022-07-07 10:45:47 +00:00
|
|
|
$info = $this->getFileScanInformation()?->toArray();
|
2020-12-22 20:11:22 +00:00
|
|
|
|
|
|
|
if (!$info) {
|
2022-07-29 10:51:20 +00:00
|
|
|
return SyncResult::error($this->filePath, $this->syncError);
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
if (!$this->isFileNew()) {
|
2022-07-07 10:45:47 +00:00
|
|
|
Arr::forget($info, $ignores);
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
$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;
|
|
|
|
|
2018-09-03 12:41:49 +00:00
|
|
|
if (!$album->has_cover) {
|
2022-07-05 13:47:26 +00:00
|
|
|
$this->tryGenerateAlbumCover($album, Arr::get($info, 'cover', []));
|
2018-09-03 12:41:49 +00:00
|
|
|
}
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
$data = Arr::except($info, ['album', 'artist', 'albumartist', 'cover']);
|
2017-12-09 02:24:09 +00:00
|
|
|
$data['album_id'] = $album->id;
|
|
|
|
$data['artist_id'] = $artist->id;
|
2022-07-05 13:47:26 +00:00
|
|
|
|
2022-08-01 10:42:33 +00:00
|
|
|
$this->song = Song::updateOrCreate(['path' => $this->filePath], $data);
|
2017-06-03 23:21:50 +00:00
|
|
|
|
2022-07-29 10:51:20 +00:00
|
|
|
return SyncResult::success($this->filePath);
|
2017-06-03 23:21:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Try to generate a cover for an album based on extracted data, or use the cover file under the directory.
|
|
|
|
*
|
2020-12-22 20:11:22 +00:00
|
|
|
* @param array<mixed>|null $coverData
|
2017-06-03 23:21:50 +00:00
|
|
|
*/
|
2022-07-05 13:47:26 +00:00
|
|
|
private function tryGenerateAlbumCover(Album $album, ?array $coverData): void
|
2017-06-03 23:21:50 +00:00
|
|
|
{
|
2022-07-05 13:47:26 +00:00
|
|
|
try {
|
|
|
|
// If the album has no cover, we try to get the cover image from existing tag data
|
|
|
|
if ($coverData) {
|
|
|
|
$extension = explode('/', $coverData['image_mime']);
|
|
|
|
$extension = $extension[1] ?? 'png';
|
2017-06-03 23:21:50 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
|
2018-08-19 09:05:33 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
return;
|
|
|
|
}
|
2016-03-22 08:22:39 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
// Or, if there's a cover image under the same directory, use it.
|
|
|
|
$cover = $this->getCoverFileUnderSameDirectory();
|
2020-12-22 20:11:22 +00:00
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
if ($cover) {
|
|
|
|
$extension = pathinfo($cover, PATHINFO_EXTENSION);
|
2022-07-16 22:42:29 +00:00
|
|
|
$this->mediaMetadataService->writeAlbumCover($album, $cover, $extension);
|
2022-07-05 13:47:26 +00:00
|
|
|
}
|
|
|
|
} catch (Throwable) {
|
2017-06-03 23:21:50 +00:00
|
|
|
}
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
|
|
|
|
2016-08-07 10:30:55 +00:00
|
|
|
/**
|
|
|
|
* Issue #380.
|
|
|
|
* Some albums have its own cover image under the same directory as cover|folder.jpg/png.
|
|
|
|
* We'll check if such a cover file is found, and use it if positive.
|
|
|
|
*/
|
2018-08-24 15:27:19 +00:00
|
|
|
private function getCoverFileUnderSameDirectory(): ?string
|
2016-08-07 10:30:55 +00:00
|
|
|
{
|
|
|
|
// As directory scanning can be expensive, we cache and reuse the result.
|
2021-06-05 10:47:56 +00:00
|
|
|
return $this->cache->remember(md5($this->filePath . '_cover'), now()->addDay(), function (): ?string {
|
2020-03-10 10:16:55 +00:00
|
|
|
$matches = array_keys(
|
|
|
|
iterator_to_array(
|
2020-03-12 15:30:52 +00:00
|
|
|
$this->finder->create()
|
2020-12-22 20:11:22 +00:00
|
|
|
->depth(0)
|
|
|
|
->ignoreUnreadableDirs()
|
|
|
|
->files()
|
|
|
|
->followLinks()
|
|
|
|
->name('/(cov|fold)er\.(jpe?g|png)$/i')
|
|
|
|
->in(dirname($this->filePath))
|
2020-03-12 15:30:52 +00:00
|
|
|
)
|
2017-08-05 21:58:50 +00:00
|
|
|
);
|
|
|
|
|
2018-08-24 15:27:19 +00:00
|
|
|
$cover = $matches ? $matches[0] : null;
|
2018-08-19 09:05:33 +00:00
|
|
|
|
2022-07-27 15:32:36 +00:00
|
|
|
return $cover && self::isImage($cover) ? $cover : null;
|
2017-08-05 21:58:50 +00:00
|
|
|
});
|
2016-08-07 10:30:55 +00:00
|
|
|
}
|
|
|
|
|
2022-07-05 13:47:26 +00:00
|
|
|
private static function isImage(string $path): bool
|
2018-10-06 10:44:25 +00:00
|
|
|
{
|
|
|
|
try {
|
2019-06-30 11:13:41 +00:00
|
|
|
return (bool) exif_imagetype($path);
|
2022-07-05 13:47:26 +00:00
|
|
|
} catch (Throwable) {
|
2018-10-06 10:44:25 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
/**
|
|
|
|
* Determine if the file is new (its Song record can't be found in the database).
|
|
|
|
*/
|
|
|
|
public function isFileNew(): bool
|
|
|
|
{
|
|
|
|
return !$this->song;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Determine if the file is changed (its Song record is found, but the timestamp is different).
|
|
|
|
*/
|
|
|
|
public function isFileChanged(): bool
|
2016-03-22 08:22:39 +00:00
|
|
|
{
|
2018-08-29 09:41:24 +00:00
|
|
|
return !$this->isFileNew() && $this->song->mtime !== $this->fileModifiedTime;
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|
2018-08-19 09:05:33 +00:00
|
|
|
|
2018-08-29 09:41:24 +00:00
|
|
|
public function isFileNewOrChanged(): bool
|
2018-08-19 09:05:33 +00:00
|
|
|
{
|
2018-08-29 09:41:24 +00:00
|
|
|
return $this->isFileNew() || $this->isFileChanged();
|
2018-08-29 06:15:11 +00:00
|
|
|
}
|
|
|
|
|
2020-06-07 20:43:04 +00:00
|
|
|
public function getSong(): ?Song
|
|
|
|
{
|
|
|
|
return $this->song;
|
|
|
|
}
|
2016-03-22 08:22:39 +00:00
|
|
|
}
|