koel/app/Services/FileSynchronizer.php

211 lines
6.4 KiB
PHP
Raw Normal View History

2016-03-22 08:22:39 +00:00
<?php
namespace App\Services;
2016-03-22 08:22:39 +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;
use App\Values\SongScanInformation;
use getID3;
use Illuminate\Contracts\Cache\Repository as Cache;
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
class FileSynchronizer
2016-03-22 08:22:39 +00:00
{
2020-06-07 20:43:04 +00:00
public const SYNC_RESULT_SUCCESS = 1;
public const SYNC_RESULT_BAD_FILE = 2;
public const SYNC_RESULT_UNMODIFIED = 3;
2018-08-29 06:15:11 +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
/**
* A (MD5) hash of the file's path.
* This value is unique, and can be used to query a Song record.
2016-03-22 08:22:39 +00:00
*/
2021-06-05 10:47:56 +00:00
private ?string $fileHash = null;
2016-03-22 08:22:39 +00:00
/**
* The song model that's associated with the current file.
*/
2021-06-05 10:47:56 +00:00
private ?Song $song;
2021-06-05 10:47:56 +00:00
private ?string $syncError;
2018-08-29 06:15:11 +00:00
public function __construct(
private getID3 $getID3,
private MediaMetadataService $mediaMetadataService,
private Helper $helper,
private SongRepository $songRepository,
private Cache $cache,
private Finder $finder
2019-06-30 11:13:41 +00:00
) {
}
2017-06-03 23:21:50 +00:00
public function setFile(string|SplFileInfo $path): self
{
2021-06-05 10:47:56 +00:00
$splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
2021-06-05 10:47:56 +00:00
$this->filePath = $splFileInfo->getPathname();
$this->fileHash = $this->helper->getFileHash($this->filePath);
2021-06-05 10:47:56 +00:00
$this->song = $this->songRepository->getOneById($this->fileHash); // @phpstan-ignore-line
2018-08-29 06:15:11 +00:00
$this->syncError = null;
return $this;
2016-03-22 08:22:39 +00:00
}
public function getFileInfo(): ?SongScanInformation
2016-03-22 08:22:39 +00:00
{
$info = $this->getID3->analyze($this->filePath);
2016-03-22 08:22:39 +00:00
2016-04-17 15:38:06 +00:00
if (isset($info['error']) || !isset($info['playtime_seconds'])) {
$this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found';
return null;
2016-03-22 08:22:39 +00:00
}
return 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
/**
* Sync the song with all available media info into the database.
2016-03-22 08:22:39 +00:00
*
* @param array<string> $excludes The tags to 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
*/
public function sync(array $excludes = [], bool $force = false): int
2016-03-22 08:22:39 +00:00
{
if (!$this->isFileNewOrChanged() && !$force) {
2017-06-03 23:21:50 +00:00
return self::SYNC_RESULT_UNMODIFIED;
2016-03-22 08:22:39 +00:00
}
$info = $this->getFileInfo()?->toArray();
2020-12-22 20:11:22 +00:00
if (!$info) {
2017-06-03 23:21:50 +00:00
return self::SYNC_RESULT_BAD_FILE;
2016-03-22 08:22:39 +00:00
}
if (!$this->isFileNew()) {
foreach ($excludes as $tag) {
unset($info[$tag]);
2016-06-04 18:17:27 +00:00
}
2016-03-22 08:22:39 +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;
if (!$album->has_cover) {
$this->tryGenerateAlbumCover($album, Arr::get($info, 'cover', []));
}
2016-03-22 08:22:39 +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;
$this->song = Song::updateOrCreate(['id' => $this->fileHash], $data);
2017-06-03 23:21:50 +00:00
return self::SYNC_RESULT_SUCCESS;
}
/**
* 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
*/
private function tryGenerateAlbumCover(Album $album, ?array $coverData): void
2017-06-03 23:21:50 +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
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
2018-08-19 09:05:33 +00:00
return;
}
2016-03-22 08:22:39 +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
if ($cover) {
$extension = pathinfo($cover, PATHINFO_EXTENSION);
$this->mediaMetadataService->writeAlbumCover($album, file_get_contents($cover), $extension);
}
} catch (Throwable) {
2017-06-03 23:21:50 +00:00
}
2016-03-22 08:22:39 +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
{
// 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(
$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))
)
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
return $cover && static::isImage($cover) ? $cover : null;
2017-08-05 21:58:50 +00:00
});
}
private static function isImage(string $path): bool
{
try {
2019-06-30 11:13:41 +00:00
return (bool) exif_imagetype($path);
} catch (Throwable) {
return false;
}
}
/**
* 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
{
return !$this->isFileNew() && $this->song->mtime !== $this->fileModifiedTime;
2016-03-22 08:22:39 +00:00
}
2018-08-19 09:05:33 +00:00
public function isFileNewOrChanged(): bool
2018-08-19 09:05:33 +00:00
{
return $this->isFileNew() || $this->isFileChanged();
2018-08-29 06:15:11 +00:00
}
public function getSyncError(): ?string
2018-08-29 06:15:11 +00:00
{
return $this->syncError;
2018-08-19 09:05:33 +00:00
}
2019-06-30 13:35:56 +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
}