koel/app/Models/File.php

397 lines
12 KiB
PHP
Raw Normal View History

2016-03-22 08:22:39 +00:00
<?php
namespace App\Models;
2018-08-29 06:15:11 +00:00
use App\Repositories\SongRepository;
use App\Services\HelperService;
2018-08-19 09:05:33 +00:00
use App\Services\MediaMetadataService;
2017-08-05 21:58:50 +00:00
use Cache;
2016-03-22 08:22:39 +00:00
use Exception;
use getID3;
2018-08-19 09:05:33 +00:00
use getid3_exception;
2016-03-22 08:22:39 +00:00
use getid3_lib;
2018-08-19 09:05:33 +00:00
use InvalidArgumentException;
2016-03-22 08:22:39 +00:00
use SplFileInfo;
2016-08-07 10:31:19 +00:00
use Symfony\Component\Finder\Finder;
2016-03-22 08:22:39 +00:00
class File
{
2018-08-29 06:15:11 +00:00
const SYNC_RESULT_SUCCESS = 1;
const SYNC_RESULT_BAD_FILE = 2;
const SYNC_RESULT_UNMODIFIED = 3;
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.
*
* @var string
*/
protected $hash;
/**
* The file's last modified time.
*
* @var int
*/
protected $mtime;
/**
* The file's path.
*/
protected $path;
/**
* The getID3 object, for ID3 tag reading.
*/
protected $getID3;
2018-08-19 09:05:33 +00:00
/**
* @var MediaMetadataService
*/
private $mediaMetadataService;
2016-03-22 08:22:39 +00:00
/**
* 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;
2018-08-29 06:15:11 +00:00
/**
* @var HelperService
*/
private $helperService;
/**
* @var SongRepository
*/
private $songRepository;
2017-06-03 23:21:50 +00:00
2016-03-22 08:22:39 +00:00
/**
* Construct our File object.
2016-03-27 02:54:22 +00:00
* Upon construction, we'll set the path, hash, and associated Song object (if any).
2016-03-22 08:22:39 +00:00
*
2018-08-29 07:07:44 +00:00
* @param string|SplFileInfo $path Either the file's path, or a SplFileInfo object
2018-08-19 09:05:33 +00:00
*
* @throws getid3_exception
2018-08-29 06:15:11 +00:00
*
* @todo Refactor this bloated, anti-pattern monster.
2016-03-22 08:22:39 +00:00
*/
2018-08-29 06:15:11 +00:00
public function __construct(
$path,
?getID3 $getID3 = null,
?MediaMetadataService $mediaMetadataService = null,
?HelperService $helperService = null,
?SongRepository $songRepository = null
2018-08-29 07:07:44 +00:00
) {
2016-04-05 07:38:10 +00:00
$this->splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
2016-03-22 08:22:39 +00:00
$this->setGetID3($getID3);
2018-08-19 09:05:33 +00:00
$this->setMediaMetadataService($mediaMetadataService);
2018-08-29 06:15:11 +00:00
$this->setHelperService($helperService);
$this->setSongRepository($songRepository);
2017-12-09 02:24:09 +00:00
// Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows.
try {
$this->mtime = $this->splFileInfo->getMTime();
} catch (Exception $e) {
// Not worth logging the error. Just use current stamp for mtime.
$this->mtime = time();
}
2016-03-22 08:22:39 +00:00
$this->path = $this->splFileInfo->getPathname();
2018-08-29 06:15:11 +00:00
$this->hash = $this->helperService->getFileHash($this->path);
$this->song = $this->songRepository->getOneById($this->hash);
$this->syncError = null;
2016-03-22 08:22:39 +00:00
}
/**
* Get all applicable ID3 info from the file.
*/
2018-08-24 15:27:19 +00:00
public function getInfo(): array
2016-03-22 08:22:39 +00:00
{
$info = $this->getID3->analyze($this->path);
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';
2017-06-03 23:21:50 +00:00
return [];
2016-03-22 08:22:39 +00:00
}
// Copy the available tags over to comment.
// This is a helper from getID3, though it doesn't really work well.
// We'll still prefer getting ID3v2 tags directly later.
// Read on.
getid3_lib::CopyTagsToComments($info);
$track = 0;
2016-03-24 03:06:28 +00:00
// Apparently track number can be stored with different indices as the following.
$trackIndices = [
'comments.track',
'comments.tracknumber',
'comments.track_number',
];
2016-03-24 03:06:28 +00:00
2017-11-08 13:11:45 +00:00
for ($i = 0; $i < count($trackIndices) && $track === 0; $i++) {
$track = array_get($info, $trackIndices[$i], [0])[0];
}
2016-03-22 08:22:39 +00:00
$props = [
'artist' => '',
'album' => '',
'compilation' => false,
'title' => basename($this->path, '.'.pathinfo($this->path, PATHINFO_EXTENSION)), // default to be file name
2016-03-22 08:22:39 +00:00
'length' => $info['playtime_seconds'],
2016-03-24 03:06:28 +00:00
'track' => (int) $track,
'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1),
2016-03-22 08:22:39 +00:00
'lyrics' => '',
'cover' => array_get($info, 'comments.picture', [null])[0],
'path' => $this->path,
'mtime' => $this->mtime,
];
if (!$comments = array_get($info, 'comments_html')) {
return $props;
}
2017-12-09 02:24:09 +00:00
$propertyMap = [
'artist' => 'artist',
'albumartist' => 'band',
'album' => 'album',
'title' => 'title',
2018-04-14 21:16:04 +00:00
'lyrics' => 'unsychronised_lyric', // this tag name is mispelled by getID3
2017-12-09 02:24:09 +00:00
'compilation' => 'part_of_a_compilation',
];
2016-03-22 08:22:39 +00:00
2017-12-09 02:24:09 +00:00
foreach ($propertyMap as $name => $tag) {
$props[$name] = array_get($info, "tags.id3v2.$tag", [null])[0] ?: array_get($comments, $tag, [''])[0];
// Fixes #323, where tag names can be htmlentities()'ed
if (is_string($props[$name]) && $props[$name]) {
$props[$name] = trim(html_entity_decode($props[$name]));
}
}
2016-03-22 08:22:39 +00:00
2017-06-03 16:35:08 +00:00
// A "compilation" property can be determined by:
// - "part_of_a_compilation" tag (used by iTunes), or
// - "albumartist" (used by non-retarded applications).
// Also, the latter is only valid if the value is NOT the same as "artist".
2017-12-09 02:24:09 +00:00
if (!$props['compilation']) {
$props['compilation'] = $props['albumartist'] && $props['artist'] !== $props['albumartist'];
}
2017-04-23 16:01:02 +00:00
return $props;
2016-03-22 08:22:39 +00:00
}
/**
* Sync the song with all available media info against the database.
*
2018-08-24 15:27:19 +00:00
* @param string[] $tags The (selective) tags to sync (if the song exists)
2018-08-29 07:07:44 +00:00
* @param bool $force Whether to force syncing, even if the file is unchanged
2016-03-22 08:22:39 +00:00
*
* @return bool|Song A Song object on success,
* true if file exists but is unmodified,
* or false on an error.
*/
2018-08-24 15:27:19 +00:00
public function sync(array $tags, bool $force = false)
2016-03-22 08:22:39 +00:00
{
// If the file is not new or changed and we're not forcing update, don't do anything.
if (!$this->isNewOrChanged() && !$force) {
2017-06-03 23:21:50 +00:00
return self::SYNC_RESULT_UNMODIFIED;
2016-03-22 08:22:39 +00:00
}
// If the file is invalid, don't do anything.
if (!$info = $this->getInfo()) {
2017-06-03 23:21:50 +00:00
return self::SYNC_RESULT_BAD_FILE;
2016-03-22 08:22:39 +00:00
}
2016-07-05 10:14:12 +00:00
// Fixes #366. If the file is new, we use all tags by simply setting $force to false.
if ($this->isNew()) {
$force = false;
}
2016-03-22 08:22:39 +00:00
if ($this->isChanged() || $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
// We cater for these tags by removing those not specified.
2016-06-04 18:17:27 +00:00
// There's a special case with 'album' though.
// If 'compilation' tag is specified, 'album' must be counted in as well.
2016-06-04 18:17:27 +00:00
// But if 'album' isn't specified, we don't want to update normal albums.
// This variable is to keep track of this state.
$changeCompilationAlbumOnly = false;
2016-08-16 15:12:11 +00:00
if (in_array('compilation', $tags, true) && !in_array('album', $tags, true)) {
$tags[] = 'album';
2016-06-04 18:17:27 +00:00
$changeCompilationAlbumOnly = true;
}
2016-03-22 08:22:39 +00:00
$info = array_intersect_key($info, array_flip($tags));
// If the "artist" tag is specified, use it.
// Otherwise, re-use the existing model value.
2016-03-22 08:22:39 +00:00
$artist = isset($info['artist']) ? Artist::get($info['artist']) : $this->song->album->artist;
$isCompilation = (bool) array_get($info, 'compilation');
// If the "album" tag is specified, use it.
// Otherwise, re-use the existing model value.
2016-06-04 18:17:27 +00:00
if (isset($info['album'])) {
$album = $changeCompilationAlbumOnly
? $this->song->album
: Album::get($artist, $info['album'], $isCompilation);
} else {
$album = $this->song->album;
}
2016-03-22 08:22:39 +00:00
} else {
// The file is newly added.
$isCompilation = (bool) array_get($info, 'compilation');
$artist = Artist::get($info['artist']);
$album = Album::get($artist, $info['album'], $isCompilation);
2016-03-22 08:22:39 +00:00
}
2017-06-03 23:21:50 +00:00
$album->has_cover || $this->generateAlbumCover($album, array_get($info, 'cover'));
2016-03-22 08:22:39 +00:00
2017-12-09 02:24:09 +00:00
$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);
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.
*
2018-08-24 15:27:19 +00:00
* @param mixed[]|null $coverData
2017-06-03 23:21:50 +00:00
*/
2018-08-24 15:27:19 +00:00
private function generateAlbumCover(Album $album, ?array $coverData): void
2017-06-03 23:21:50 +00:00
{
// If the album has no cover, we try to get the cover image from existing tag data
if ($coverData) {
2018-08-19 09:05:33 +00:00
$extension = explode('/', $coverData['image_mime']);
$extension = empty($extension[1]) ? 'png' : $extension[1];
2017-06-03 23:21:50 +00:00
2018-08-19 09:05:33 +00:00
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
return;
2017-06-03 23:21:50 +00:00
}
2016-03-22 08:22:39 +00:00
2017-06-03 23:21:50 +00:00
// Or, if there's a cover image under the same directory, use it.
if ($cover = $this->getCoverFileUnderSameDirectory()) {
2018-08-19 09:05:33 +00:00
$this->mediaMetadataService->copyAlbumCover($album, $cover);
2017-06-03 23:21:50 +00:00
}
2016-03-22 08:22:39 +00:00
}
/**
* Determine if the file is new (its Song record can't be found in the database).
*/
2018-08-24 15:27:19 +00:00
public function isNew(): bool
2016-03-22 08:22:39 +00:00
{
return !$this->song;
}
/**
* Determine if the file is changed (its Song record is found, but the timestamp is different).
*/
2018-08-24 15:27:19 +00:00
public function isChanged(): bool
2016-03-22 08:22:39 +00:00
{
return !$this->isNew() && $this->song->mtime !== $this->mtime;
}
/**
* Determine if the file is new or changed.
*/
2018-08-24 15:27:19 +00:00
public function isNewOrChanged(): bool
2016-03-22 08:22:39 +00:00
{
return $this->isNew() || $this->isChanged();
}
2018-08-24 15:27:19 +00:00
public function getGetID3(): getID3
2016-03-22 08:22:39 +00:00
{
return $this->getID3;
}
/**
* Get the last parsing error's text.
*/
2018-08-24 15:27:19 +00:00
public function getSyncError(): string
{
return $this->syncError;
}
2016-03-22 08:22:39 +00:00
/**
2018-08-19 09:05:33 +00:00
* @throws getid3_exception
2016-03-22 08:22:39 +00:00
*/
2018-08-24 15:27:19 +00:00
public function setGetID3(?getID3 $getID3 = null): void
2016-03-22 08:22:39 +00:00
{
$this->getID3 = $getID3 ?: new getID3();
}
2018-08-24 15:27:19 +00:00
public function getPath(): string
2016-03-22 08:22:39 +00:00
{
return $this->path;
}
/**
* 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-19 09:05:33 +00:00
* @throws InvalidArgumentException
*/
2018-08-24 15:27:19 +00:00
private function getCoverFileUnderSameDirectory(): ?string
{
// As directory scanning can be expensive, we cache and reuse the result.
2017-08-05 21:58:50 +00:00
return Cache::remember(md5($this->path.'_cover'), 24 * 60, function () {
$matches = array_keys(iterator_to_array(
Finder::create()
->depth(0)
->ignoreUnreadableDirs()
->files()
->followLinks()
->name('/(cov|fold)er\.(jpe?g|png)$/i')
->in(dirname($this->path))
)
);
2018-08-24 15:27:19 +00:00
$cover = $matches ? $matches[0] : null;
2018-08-19 09:05:33 +00:00
2017-08-05 21:58:50 +00:00
// Even if a file is found, make sure it's a real image.
if ($cover && exif_imagetype($cover) === false) {
2018-08-24 15:27:19 +00:00
$cover = null;
2017-08-05 21:58:50 +00:00
}
return $cover;
2017-08-05 21:58:50 +00:00
});
}
2018-08-29 06:15:11 +00:00
private function setMediaMetadataService(?MediaMetadataService $mediaMetadataService = null): void
2016-03-22 08:22:39 +00:00
{
2018-08-29 06:15:11 +00:00
$this->mediaMetadataService = $mediaMetadataService ?: app(MediaMetadataService::class);
2016-03-22 08:22:39 +00:00
}
2018-08-19 09:05:33 +00:00
2018-08-29 06:15:11 +00:00
private function setHelperService(?HelperService $helperService = null): void
2018-08-19 09:05:33 +00:00
{
2018-08-29 06:15:11 +00:00
$this->helperService = $helperService ?: app(HelperService::class);
}
public function setSongRepository(?SongRepository $songRepository = null): void
{
$this->songRepository = $songRepository ?: app(SongRepository::class);
2018-08-19 09:05:33 +00:00
}
2016-03-22 08:22:39 +00:00
}