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
|
|||
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 |
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.