Issues (49)

app/Services/FileSynchronizer.php (1 issue)

1
<?php
2
3
namespace App\Services;
4
5
use App\Models\Album;
6
use App\Models\Artist;
7
use App\Models\Song;
8
use App\Repositories\SongRepository;
9
use Exception;
10
use getID3;
11
use getid3_lib;
12
use Illuminate\Contracts\Cache\Repository as Cache;
13
use InvalidArgumentException;
14
use SplFileInfo;
15
use Symfony\Component\Finder\Finder;
16
17
class FileSynchronizer
18
{
19
    const SYNC_RESULT_SUCCESS = 1;
20
    const SYNC_RESULT_BAD_FILE = 2;
21
    const SYNC_RESULT_UNMODIFIED = 3;
22
23
    private $getID3;
24
    private $mediaMetadataService;
25
    private $helperService;
26
    private $songRepository;
27
    private $cache;
28
    private $finder;
29
30
    /**
31
     * @var SplFileInfo
32
     */
33
    private $splFileInfo;
34
35
    /**
36
     * @var int
37
     */
38
    private $fileModifiedTime;
39
40
    /**
41
     * @var string
42
     */
43
    private $filePath;
44
45
    /**
46
     * A (MD5) hash of the file's path.
47
     * This value is unique, and can be used to query a Song record.
48
     *
49
     * @var string
50
     */
51
    private $fileHash;
52
53
    /**
54
     * The song model that's associated with the current file.
55
     *
56
     * @var Song|null
57
     */
58
    private $song;
59
60
    /**
61
     * @var string|null
62
     */
63
    private $syncError;
64
65 132
    public function __construct(
66
        getID3 $getID3,
67
        MediaMetadataService $mediaMetadataService,
68
        HelperService $helperService,
69
        SongRepository $songRepository,
70
        Cache $cache,
71
        Finder $finder
72
    ) {
73 132
        $this->getID3 = $getID3;
74 132
        $this->mediaMetadataService = $mediaMetadataService;
75 132
        $this->helperService = $helperService;
76 132
        $this->songRepository = $songRepository;
77 132
        $this->cache = $cache;
78 132
        $this->finder = $finder;
79 132
    }
80
81
    /**
82
     * @param string|SplFileInfo $path
83
     */
84 10
    public function setFile($path): self
85
    {
86 10
        $this->splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
87
88
        // Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows.
89
        try {
90 10
            $this->fileModifiedTime = $this->splFileInfo->getMTime();
91 1
        } catch (Exception $e) {
92
            // Not worth logging the error. Just use current stamp for mtime.
93 1
            $this->fileModifiedTime = time();
94
        }
95
96 10
        $this->filePath = $this->splFileInfo->getPathname();
97 10
        $this->fileHash = $this->helperService->getFileHash($this->filePath);
98 10
        $this->song = $this->songRepository->getOneById($this->fileHash);
99 10
        $this->syncError = null;
100
101 10
        return $this;
102
    }
103
104
    /**
105
     * Get all applicable info from the file.
106
     */
107 10
    public function getFileInfo(): array
108
    {
109 10
        $info = $this->getID3->analyze($this->filePath);
110
111 10
        if (isset($info['error']) || !isset($info['playtime_seconds'])) {
112 6
            $this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found';
113
114 6
            return [];
115
        }
116
117
        // Copy the available tags over to comment.
118
        // This is a helper from getID3, though it doesn't really work well.
119
        // We'll still prefer getting ID3v2 tags directly later.
120 10
        getid3_lib::CopyTagsToComments($info);
121
122
        $props = [
123 10
            'artist' => '',
124 10
            'album' => '',
125 10
            'albumartist' => '',
126
            'compilation' => false,
127 10
            'title' => basename($this->filePath, '.'.pathinfo($this->filePath, PATHINFO_EXTENSION)), // default to be file name
128 10
            'length' => $info['playtime_seconds'],
129 10
            'track' => $this->getTrackNumberFromInfo($info),
130 10
            'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1),
131 10
            'lyrics' => '',
132 10
            'cover' => array_get($info, 'comments.picture', [null])[0],
133 10
            'path' => $this->filePath,
134 10
            'mtime' => $this->fileModifiedTime,
135
        ];
136
137 10
        if (!$comments = array_get($info, 'comments_html')) {
138 8
            return $props;
139
        }
140
141 8
        $this->gatherPropsFromTags($info, $comments, $props);
142 8
        $props['compilation'] = (bool) $props['compilation'] || $this->isCompilation($props);
143
144 8
        return $props;
145
    }
146
147
    /**
148
     * Sync the song with all available media info against the database.
149
     *
150
     * @param string[] $tags  The (selective) tags to sync (if the song exists)
151
     * @param bool     $force Whether to force syncing, even if the file is unchanged
152
     */
153 7
    public function sync(array $tags, bool $force = false): int
154
    {
155 7
        if (!$this->isFileNewOrChanged() && !$force) {
156 3
            return self::SYNC_RESULT_UNMODIFIED;
157
        }
158
159 7
        if (!$info = $this->getFileInfo()) {
160 6
            return self::SYNC_RESULT_BAD_FILE;
161
        }
162
163
        // Fixes #366. If the file is new, we use all tags by simply setting $force to false.
164 7
        if ($this->isFileNew()) {
165 7
            $force = false;
166
        }
167
168 7
        if ($this->isFileChanged() || $force) {
169
            // This is a changed file, or the user is forcing updates.
170
            // In such a case, the user must have specified a list of tags to sync.
171
            // A sample command could be: ./artisan koel:sync --force --tags=artist,album,lyrics
172
            // We cater for these tags by removing those not specified.
173
174
            // There's a special case with 'album' though.
175
            // If 'compilation' tag is specified, 'album' must be counted in as well.
176
            // But if 'album' isn't specified, we don't want to update normal albums.
177
            // This variable is to keep track of this state.
178 4
            $changeCompilationAlbumOnly = false;
179
180 4
            if (in_array('compilation', $tags, true) && !in_array('album', $tags, true)) {
181
                $tags[] = 'album';
182
                $changeCompilationAlbumOnly = true;
183
            }
184
185 4
            $info = array_intersect_key($info, array_flip($tags));
186
187
            // If the "artist" tag is specified, use it.
188
            // Otherwise, re-use the existing model value.
189 4
            $artist = isset($info['artist']) ? Artist::get($info['artist']) : $this->song->album->artist;
190
191
            // If the "album" tag is specified, use it.
192
            // Otherwise, re-use the existing model value.
193 4
            if (isset($info['album'])) {
194 2
                $album = $changeCompilationAlbumOnly
195
                    ? $this->song->album
196 2
                    : Album::get($artist, $info['album'], array_get($info, 'compilation'));
197
            } else {
198 2
                $album = $this->song->album;
199
            }
200
        } else {
201
            // The file is newly added.
202 7
            $artist = Artist::get($info['artist']);
203 7
            $album = Album::get($artist, $info['album'], array_get($info, 'compilation'));
204
        }
205
206 7
        if (!$album->has_cover) {
207 7
            $this->generateAlbumCover($album, array_get($info, 'cover'));
208
        }
209
210 7
        $data = array_except($info, ['artist', 'albumartist', 'album', 'cover', 'compilation']);
211 7
        $data['album_id'] = $album->id;
212 7
        $data['artist_id'] = $artist->id;
213 7
        $this->song = Song::updateOrCreate(['id' => $this->fileHash], $data);
214
215 7
        return self::SYNC_RESULT_SUCCESS;
216
    }
217
218
    /**
219
     * Try to generate a cover for an album based on extracted data, or use the cover file under the directory.
220
     *
221
     * @param mixed[]|null $coverData
222
     */
223 7
    private function generateAlbumCover(Album $album, ?array $coverData): void
224
    {
225
        // If the album has no cover, we try to get the cover image from existing tag data
226 7
        if ($coverData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $coverData of type array<mixed,mixed> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
227 6
            $extension = explode('/', $coverData['image_mime']);
228 6
            $extension = empty($extension[1]) ? 'png' : $extension[1];
229
230 6
            $this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
231
232 6
            return;
233
        }
234
235
        // Or, if there's a cover image under the same directory, use it.
236 7
        if ($cover = $this->getCoverFileUnderSameDirectory()) {
237 6
            $this->mediaMetadataService->copyAlbumCover($album, $cover);
238
        }
239 7
    }
240
241
    /**
242
     * Issue #380.
243
     * Some albums have its own cover image under the same directory as cover|folder.jpg/png.
244
     * We'll check if such a cover file is found, and use it if positive.
245
     *
246
     * @throws InvalidArgumentException
247
     */
248 7
    private function getCoverFileUnderSameDirectory(): ?string
249
    {
250
        // As directory scanning can be expensive, we cache and reuse the result.
251
        return $this->cache->remember(md5($this->filePath.'_cover'), 24 * 60, function (): ?string {
252 7
            $matches = array_keys(
253 7
                iterator_to_array(
254 7
                    $this->finder->create()
255 7
                    ->depth(0)
256 7
                    ->ignoreUnreadableDirs()
257 7
                    ->files()
258 7
                    ->followLinks()
259 7
                    ->name('/(cov|fold)er\.(jpe?g|png)$/i')
260 7
                    ->in(dirname($this->filePath))
261
                )
262
            );
263
264 7
            $cover = $matches ? $matches[0] : null;
265
266 7
            return $cover && $this->isImage($cover) ? $cover : null;
267 7
        });
268
    }
269
270 6
    private function isImage(string $path): bool
271
    {
272
        try {
273 6
            return (bool) exif_imagetype($path);
274
        } catch (Exception $e) {
275
            return false;
276
        }
277
    }
278
279
    /**
280
     * Determine if the file is new (its Song record can't be found in the database).
281
     */
282 7
    public function isFileNew(): bool
283
    {
284 7
        return !$this->song;
285
    }
286
287
    /**
288
     * Determine if the file is changed (its Song record is found, but the timestamp is different).
289
     */
290 7
    public function isFileChanged(): bool
291
    {
292 7
        return !$this->isFileNew() && $this->song->mtime !== $this->fileModifiedTime;
293
    }
294
295 7
    public function isFileNewOrChanged(): bool
296
    {
297 7
        return $this->isFileNew() || $this->isFileChanged();
298
    }
299
300
    public function getSyncError(): ?string
301
    {
302
        return $this->syncError;
303
    }
304
305 10
    private function getTrackNumberFromInfo(array $info): int
306
    {
307 10
        $track = 0;
308
309
        // Apparently track numbers can be stored with different indices as the following.
310
        $trackIndices = [
311 10
            'comments.track',
312
            'comments.tracknumber',
313
            'comments.track_number',
314
        ];
315
316 10
        for ($i = 0; $i < count($trackIndices) && $track === 0; $i++) {
317 10
            $track = (int) array_get($info, $trackIndices[$i], [0])[0];
318
        }
319
320 10
        return $track;
321
    }
322
323 8
    private function gatherPropsFromTags(array $info, array $comments, array &$props): void
324
    {
325
        $propertyMap = [
326 8
            'artist' => 'artist',
327
            'albumartist' => 'band',
328
            'album' => 'album',
329
            'title' => 'title',
330
            'lyrics' => ['unsychronised_lyric', 'unsynchronised_lyric'],
331
            'compilation' => 'part_of_a_compilation',
332
        ];
333
334 8
        foreach ($propertyMap as $name => $tags) {
335 8
            foreach ((array) $tags as $tag) {
336 8
                $value = array_get($info, "tags.id3v2.$tag", [null])[0] ?: array_get($comments, $tag, [''])[0];
337
338 8
                if ($value) {
339 8
                    $props[$name] = $value;
340
                }
341
            }
342
343
            // Fixes #323, where tag names can be htmlentities()'ed
344 8
            if (is_string($props[$name]) && $props[$name]) {
345 8
                $props[$name] = trim(html_entity_decode($props[$name]));
346
            }
347
        }
348 8
    }
349
350 8
    private function isCompilation(array $props): bool
351
    {
352
        // A "compilation" property can be determined by:
353
        // - "part_of_a_compilation" tag (used by iTunes), or
354
        // - "albumartist" (used by non-retarded applications).
355
        // Also, the latter is only valid if the value is NOT the same as "artist".
356 8
        return $props['albumartist'] && $props['artist'] !== $props['albumartist'];
357
    }
358
}
359