1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* ownCloud - Music app |
5
|
|
|
* |
6
|
|
|
* This file is licensed under the Affero General Public License version 3 or |
7
|
|
|
* later. See the COPYING file. |
8
|
|
|
* |
9
|
|
|
* @author Morris Jobke <[email protected]> |
10
|
|
|
* @copyright Morris Jobke 2013, 2014 |
11
|
|
|
*/ |
12
|
|
|
|
13
|
|
|
namespace OCA\Music\Utility; |
14
|
|
|
|
15
|
|
|
use OC\Hooks\PublicEmitter; |
16
|
|
|
|
17
|
|
|
use \OCP\Files\Folder; |
18
|
|
|
use \OCP\IConfig; |
19
|
|
|
|
20
|
|
|
use \OCA\Music\AppFramework\Core\Logger; |
21
|
|
|
|
22
|
|
|
use \OCA\Music\BusinessLayer\ArtistBusinessLayer; |
23
|
|
|
use \OCA\Music\BusinessLayer\AlbumBusinessLayer; |
24
|
|
|
use \OCA\Music\BusinessLayer\TrackBusinessLayer; |
25
|
|
|
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer; |
26
|
|
|
use \OCA\Music\Db\Cache; |
27
|
|
|
use OCP\IDBConnection; |
28
|
|
|
use Symfony\Component\Console\Output\OutputInterface; |
29
|
|
|
|
30
|
|
|
|
31
|
|
|
class Scanner extends PublicEmitter { |
32
|
|
|
|
33
|
|
|
private $extractor; |
34
|
|
|
private $artistBusinessLayer; |
35
|
|
|
private $albumBusinessLayer; |
36
|
|
|
private $trackBusinessLayer; |
37
|
|
|
private $playlistBusinessLayer; |
38
|
|
|
private $cache; |
39
|
|
|
private $coverHelper; |
40
|
|
|
private $logger; |
41
|
|
|
/** @var IDBConnection */ |
42
|
|
|
private $db; |
43
|
|
|
private $configManager; |
44
|
|
|
private $appName; |
45
|
|
|
private $rootFolder; |
46
|
|
|
|
47
|
|
|
public function __construct(Extractor $extractor, |
48
|
|
|
ArtistBusinessLayer $artistBusinessLayer, |
49
|
|
|
AlbumBusinessLayer $albumBusinessLayer, |
50
|
|
|
TrackBusinessLayer $trackBusinessLayer, |
51
|
|
|
PlaylistBusinessLayer $playlistBusinessLayer, |
52
|
|
|
Cache $cache, |
53
|
|
|
CoverHelper $coverHelper, |
54
|
|
|
Logger $logger, |
55
|
|
|
IDBConnection $db, |
56
|
|
|
IConfig $configManager, |
57
|
|
|
$appName, |
58
|
|
|
Folder $rootFolder){ |
59
|
|
|
$this->extractor = $extractor; |
60
|
|
|
$this->artistBusinessLayer = $artistBusinessLayer; |
61
|
|
|
$this->albumBusinessLayer = $albumBusinessLayer; |
62
|
|
|
$this->trackBusinessLayer = $trackBusinessLayer; |
63
|
|
|
$this->playlistBusinessLayer = $playlistBusinessLayer; |
64
|
|
|
$this->cache = $cache; |
65
|
|
|
$this->coverHelper = $coverHelper; |
66
|
|
|
$this->logger = $logger; |
67
|
|
|
$this->db = $db; |
68
|
|
|
$this->configManager = $configManager; |
69
|
|
|
$this->appName = $appName; |
70
|
|
|
$this->rootFolder = $rootFolder; |
71
|
|
|
|
72
|
|
|
// Trying to enable stream support |
73
|
|
|
if(ini_get('allow_url_fopen') !== '1') { |
74
|
|
|
$this->logger->log('allow_url_fopen is disabled. It is strongly advised to enable it in your php.ini', 'warn'); |
75
|
|
|
@ini_set('allow_url_fopen', '1'); |
76
|
|
|
} |
77
|
|
|
} |
78
|
|
|
|
79
|
|
|
/** |
80
|
|
|
* Gets called by 'post_write' (file creation, file update) and 'post_share' hooks |
81
|
|
|
* @param \OCP\Files\File $file the file |
82
|
|
|
* @param string userId |
83
|
|
|
* @param \OCP\Files\Folder $userHome |
84
|
|
|
* @param string|null $filePath Deducted from $file if not given |
85
|
|
|
*/ |
86
|
|
|
public function update($file, $userId, $userHome, $filePath = null){ |
87
|
|
|
if ($filePath === null) { |
88
|
|
|
$filePath = $file->getPath(); |
89
|
|
|
} |
90
|
|
|
|
91
|
|
|
// debug logging |
92
|
|
|
$this->logger->log("update - $filePath", 'debug'); |
93
|
|
|
|
94
|
|
|
if(!($file instanceof \OCP\Files\File) || !$userId || !($userHome instanceof \OCP\Files\Folder)) { |
|
|
|
|
95
|
|
|
$this->logger->log('Invalid arguments given to Scanner.update - file='.get_class($file). |
96
|
|
|
", userId=$userId, userHome=".get_class($userHome), 'warn'); |
97
|
|
|
return; |
98
|
|
|
} |
99
|
|
|
|
100
|
|
|
// skip files that aren't inside the user specified path |
101
|
|
|
if(!$this->pathIsUnderMusicFolder($filePath, $userId, $userHome)) { |
102
|
|
|
$this->logger->log("skipped - file is outside of specified music folder", 'debug'); |
103
|
|
|
return; |
104
|
|
|
} |
105
|
|
|
|
106
|
|
|
$mimetype = $file->getMimeType(); |
107
|
|
|
|
108
|
|
|
// debug logging |
109
|
|
|
$this->logger->log("update - mimetype $mimetype", 'debug'); |
110
|
|
|
$this->emit('\OCA\Music\Utility\Scanner', 'update', array($filePath)); |
111
|
|
|
|
112
|
|
|
if(self::startsWith($mimetype, 'image')) { |
113
|
|
|
$this->updateImage($file, $userId); |
114
|
|
|
} |
115
|
|
|
else if(self::startsWith($mimetype, 'audio') || self::startsWith($mimetype, 'application/ogg')) { |
116
|
|
|
$this->updateAudio($file, $userId, $userHome, $filePath, $mimetype); |
117
|
|
|
} |
118
|
|
|
} |
119
|
|
|
|
120
|
|
|
private function pathIsUnderMusicFolder($filePath, $userId, $userHome) { |
121
|
|
|
$musicFolder = $this->getUserMusicFolder($userId, $userHome); |
122
|
|
|
$musicPath = $musicFolder->getPath(); |
123
|
|
|
return self::startsWith($filePath, $musicPath); |
124
|
|
|
} |
125
|
|
|
|
126
|
|
|
private function updateImage($file, $userId) { |
127
|
|
|
$coverFileId = $file->getId(); |
128
|
|
|
$parentFolderId = $file->getParent()->getId(); |
129
|
|
|
if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) { |
130
|
|
|
$this->logger->log('updateImage - the image was set as cover for some album(s)', 'debug'); |
131
|
|
|
$this->cache->remove($userId); |
132
|
|
|
} |
133
|
|
|
} |
134
|
|
|
|
135
|
|
|
private function updateAudio($file, $userId, $userHome, $filePath, $mimetype) { |
136
|
|
|
if(ini_get('allow_url_fopen')) { |
137
|
|
|
|
138
|
|
|
$meta = $this->extractMetadata($file, $userHome, $filePath); |
139
|
|
|
$fileId = $file->getId(); |
140
|
|
|
|
141
|
|
|
// debug logging |
142
|
|
|
$this->logger->log('extracted metadata - ' . json_encode($meta), 'debug'); |
143
|
|
|
|
144
|
|
|
// add/update artist and get artist entity |
145
|
|
|
$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId); |
146
|
|
|
$artistId = $artist->getId(); |
147
|
|
|
|
148
|
|
|
// add/update albumArtist and get artist entity |
149
|
|
|
$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId); |
150
|
|
|
$albumArtistId = $albumArtist->getId(); |
151
|
|
|
|
152
|
|
|
// add/update album and get album entity |
153
|
|
|
$album = $this->albumBusinessLayer->addOrUpdateAlbum( |
154
|
|
|
$meta['album'], $meta['discNumber'], $albumArtistId, $userId); |
155
|
|
|
$albumId = $album->getId(); |
156
|
|
|
|
157
|
|
|
// add/update track and get track entity |
158
|
|
|
$track = $this->trackBusinessLayer->addOrUpdateTrack($meta['title'], $meta['trackNumber'], $meta['year'], |
159
|
|
|
$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']); |
160
|
|
|
|
161
|
|
|
// if present, use the embedded album art as cover for the respective album |
162
|
|
|
if($meta['picture'] != null) { |
163
|
|
|
$this->albumBusinessLayer->setCover($fileId, $albumId); |
164
|
|
|
$this->coverHelper->removeCoverFromCache($albumId, $userId); |
165
|
|
|
$this->coverHelper->addCoverToCache($albumId, $userId, $meta['picture']); |
166
|
|
|
} |
167
|
|
|
// if this file is an existing file which previously was used as cover for an album but now |
168
|
|
|
// the file no longer contains any embedded album art |
169
|
|
|
else if($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, [$fileId])) { |
170
|
|
|
$this->albumBusinessLayer->removeCovers([$fileId]); |
171
|
|
|
$this->findEmbeddedCoverForAlbum($albumId, $userId, $userHome); |
172
|
|
|
$this->coverHelper->removeCoverFromCache($albumId, $userId); |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
// invalidate the cache as the music collection was changed |
176
|
|
|
$this->cache->remove($userId, 'collection'); |
177
|
|
|
|
178
|
|
|
// debug logging |
179
|
|
|
$this->logger->log('imported entities - ' . |
180
|
|
|
"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}", |
181
|
|
|
'debug'); |
182
|
|
|
} |
183
|
|
|
} |
184
|
|
|
|
185
|
|
|
private function extractMetadata($file, $userHome, $filePath) { |
186
|
|
|
$fieldsFromFileName = self::parseFileName($file->getName()); |
187
|
|
|
$fileInfo = $this->extractor->extract($file); |
188
|
|
|
$meta = []; |
189
|
|
|
|
190
|
|
|
// Track artist and album artist |
191
|
|
|
$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist'); |
192
|
|
|
$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']); |
193
|
|
|
|
194
|
|
|
// use artist and albumArtist as fallbacks for each other |
195
|
|
|
if(self::isNullOrEmpty($meta['albumArtist'])){ |
196
|
|
|
$meta['albumArtist'] = $meta['artist']; |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
if(self::isNullOrEmpty($meta['artist'])){ |
200
|
|
|
$meta['artist'] = $meta['albumArtist']; |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
// set 'Unknown Artist' in case neither artist nor albumArtist was found |
204
|
|
|
if(self::isNullOrEmpty($meta['artist'])){ |
205
|
|
|
$meta['artist'] = null; |
206
|
|
|
$meta['albumArtist'] = null; |
207
|
|
|
} |
208
|
|
|
|
209
|
|
|
// title |
210
|
|
|
$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title'); |
211
|
|
|
if(self::isNullOrEmpty($meta['title'])){ |
212
|
|
|
$meta['title'] = $fieldsFromFileName['title']; |
213
|
|
|
} |
214
|
|
|
|
215
|
|
|
// album |
216
|
|
|
$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album'); |
217
|
|
|
if(self::isNullOrEmpty($meta['album'])){ |
218
|
|
|
// album name not set in fileinfo, use parent folder name as album name unless it is the root folder |
219
|
|
|
$dirPath = dirname($filePath); |
220
|
|
|
if ($userHome->getPath() === $dirPath) { |
221
|
|
|
$meta['album'] = null; |
222
|
|
|
} else { |
223
|
|
|
$meta['album'] = basename($dirPath); |
224
|
|
|
} |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
// track number |
228
|
|
|
$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['track_number', 'tracknumber', 'track'], |
229
|
|
|
$fieldsFromFileName['track_number']); |
230
|
|
|
$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']); |
231
|
|
|
|
232
|
|
|
// disc number |
233
|
|
|
$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['discnumber', 'part_of_a_set'], '1'); |
234
|
|
|
$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']); |
235
|
|
|
|
236
|
|
|
// year |
237
|
|
|
$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date']); |
238
|
|
|
$meta['year'] = self::normalizeYear($meta['year']); |
239
|
|
|
|
240
|
|
|
$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true); |
241
|
|
|
|
242
|
|
|
if (array_key_exists('playtime_seconds', $fileInfo)) { |
243
|
|
|
$meta['length'] = ceil($fileInfo['playtime_seconds']); |
244
|
|
|
} else { |
245
|
|
|
$meta['length'] = null; |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
if (array_key_exists('audio', $fileInfo) && array_key_exists('bitrate', $fileInfo['audio'])) { |
249
|
|
|
$meta['bitrate'] = $fileInfo['audio']['bitrate']; |
250
|
|
|
} else { |
251
|
|
|
$meta['bitrate'] = null; |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
return $meta; |
255
|
|
|
} |
256
|
|
|
|
257
|
|
|
/** |
258
|
|
|
* @param int[] $fileIds |
259
|
|
|
* @param string|null $userId |
260
|
|
|
* @return boolean true if anything was removed |
261
|
|
|
*/ |
262
|
|
|
private function deleteAudio($fileIds, $userId=null){ |
263
|
|
|
$this->logger->log('deleteAudio - '. implode(', ', $fileIds) , 'debug'); |
264
|
|
|
$this->emit('\OCA\Music\Utility\Scanner', 'delete', array($fileIds, $userId)); |
265
|
|
|
|
266
|
|
|
$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userId); |
267
|
|
|
|
268
|
|
|
if ($result) { // one or more tracks were removed |
269
|
|
|
// remove obsolete artists and albums, and track references in playlists |
270
|
|
|
$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']); |
271
|
|
|
$this->artistBusinessLayer->deleteById($result['obsoleteArtists']); |
272
|
|
|
$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']); |
273
|
|
|
|
274
|
|
|
// check if a removed track was used as embedded cover art file for a remaining album |
275
|
|
|
foreach ($result['remainingAlbums'] as $albumId) { |
276
|
|
|
if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) { |
277
|
|
|
$this->albumBusinessLayer->setCover(null, $albumId); |
278
|
|
|
$this->findEmbeddedCoverForAlbum($albumId); |
279
|
|
|
$this->coverHelper->removeCoverFromCache($albumId, $userId); |
280
|
|
|
} |
281
|
|
|
} |
282
|
|
|
|
283
|
|
|
// invalidate the cache of all affected users as their music collections were changed |
284
|
|
|
foreach ($result['affectedUsers'] as $affectedUser) { |
285
|
|
|
$this->cache->remove($affectedUser, 'collection'); |
286
|
|
|
} |
287
|
|
|
|
288
|
|
|
$this->logger->log('removed entities - ' . json_encode($result), 'debug'); |
289
|
|
|
} |
290
|
|
|
|
291
|
|
|
return $result !== false; |
292
|
|
|
} |
293
|
|
|
|
294
|
|
|
/** |
295
|
|
|
* @param int[] $fileIds |
296
|
|
|
* @param string|null $userId |
297
|
|
|
* @return boolean true if anything was removed |
298
|
|
|
*/ |
299
|
|
|
private function deleteImage($fileIds, $userId=null){ |
300
|
|
|
$this->logger->log('deleteImage - '. implode(', ', $fileIds) , 'debug'); |
301
|
|
|
|
302
|
|
|
$affectedUsers = $this->albumBusinessLayer->removeCovers($fileIds, $userId); |
303
|
|
|
$deleted = (count($affectedUsers) > 0); |
304
|
|
|
if ($deleted) { |
305
|
|
|
foreach ($affectedUsers as $affectedUser) { |
306
|
|
|
$this->cache->remove($affectedUser); |
307
|
|
|
} |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
return $deleted; |
311
|
|
|
} |
312
|
|
|
|
313
|
|
|
/** |
314
|
|
|
* Gets called by 'unshare' hook and 'delete' hook |
315
|
|
|
* |
316
|
|
|
* @param int $fileId ID of the deleted files |
317
|
|
|
* @param string|null $userId the ID of the user to remove the file from; if omitted, |
318
|
|
|
* the file is removed from all users (ie. owner and sharees) |
319
|
|
|
*/ |
320
|
|
|
public function delete($fileId, $userId=null){ |
321
|
|
|
if (!$this->deleteAudio([$fileId], $userId) && !$this->deleteImage([$fileId], $userId)) { |
322
|
|
|
$this->logger->log("deleted file $fileId was not an indexed " . |
323
|
|
|
'audio file or a cover image' , 'debug'); |
324
|
|
|
} |
325
|
|
|
} |
326
|
|
|
|
327
|
|
|
/** |
328
|
|
|
* Remove all audio files and cover images in the given folder from the database. |
329
|
|
|
* This gets called when a folder is deleted or unshared from the user. |
330
|
|
|
* |
331
|
|
|
* @param \OCP\Files\Folder $folder |
332
|
|
|
* @param string|null $userId the id of the user to remove the folder from; if omitted, |
333
|
|
|
* the folder is removed from all users (ie. owner and sharees) |
334
|
|
|
*/ |
335
|
|
|
public function deleteFolder($folder, $userId=null) { |
336
|
|
|
$audioFiles = array_merge( |
337
|
|
|
$folder->searchByMime('audio'), |
338
|
|
|
$folder->searchByMime('application/ogg') |
339
|
|
|
); |
340
|
|
|
if (count($audioFiles) > 0) { |
341
|
|
|
$this->deleteAudio(self::idsFromArray($audioFiles), $userId); |
342
|
|
|
} |
343
|
|
|
|
344
|
|
|
$imageFiles = $folder->searchByMime('image'); |
345
|
|
|
if (count($imageFiles) > 0) { |
346
|
|
|
$this->deleteImage(self::idsFromArray($imageFiles), $userId); |
347
|
|
|
} |
348
|
|
|
} |
349
|
|
|
|
350
|
|
|
public function getUserMusicFolder($userId, $userHome) { |
351
|
|
|
$musicPath = $this->configManager->getUserValue($userId, $this->appName, 'path'); |
352
|
|
|
|
353
|
|
|
if ($musicPath !== null && $musicPath !== '/' && $musicPath !== '') { |
354
|
|
|
return $userHome->get($musicPath); |
355
|
|
|
} else { |
356
|
|
|
return $userHome; |
357
|
|
|
} |
358
|
|
|
} |
359
|
|
|
|
360
|
|
|
/** |
361
|
|
|
* search for files by mimetype inside an optional user specified path |
362
|
|
|
* |
363
|
|
|
* @return \OCP\Files\File[] |
364
|
|
|
*/ |
365
|
|
|
public function getMusicFiles($userId, $userHome) { |
366
|
|
|
try { |
367
|
|
|
$folder = $this->getUserMusicFolder($userId, $userHome); |
368
|
|
|
} catch (\OCP\Files\NotFoundException $e) { |
369
|
|
|
return array(); |
370
|
|
|
} |
371
|
|
|
|
372
|
|
|
$audio = $folder->searchByMime('audio'); |
373
|
|
|
$ogg = $folder->searchByMime('application/ogg'); |
374
|
|
|
|
375
|
|
|
return array_merge($audio, $ogg); |
376
|
|
|
} |
377
|
|
|
|
378
|
|
|
public function getScannedFiles($userId) { |
379
|
|
|
return $this->trackBusinessLayer->findAllFileIds($userId); |
380
|
|
|
} |
381
|
|
|
|
382
|
|
|
public function getUnscannedMusicFileIds($userId, $userHome) { |
383
|
|
|
$scannedIds = $this->getScannedFiles($userId); |
384
|
|
|
$musicFiles = $this->getMusicFiles($userId, $userHome); |
385
|
|
|
$allIds = self::idsFromArray($musicFiles); |
386
|
|
|
$unscannedIds = array_values(array_diff($allIds, $scannedIds)); |
387
|
|
|
|
388
|
|
|
$count = count($unscannedIds); |
389
|
|
|
if ($count) { |
390
|
|
|
$this->logger->log("Found $count unscanned music files for user $userId", 'info'); |
391
|
|
|
} else { |
392
|
|
|
$this->logger->log("No unscanned music files for user $userId", 'debug'); |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
return $unscannedIds; |
396
|
|
|
} |
397
|
|
|
|
398
|
|
|
public function scanFiles($userId, $userHome, $fileIds, OutputInterface $debugOutput = null) { |
399
|
|
|
$count = count($fileIds); |
400
|
|
|
$this->logger->log("Scanning $count files of user $userId", 'debug'); |
401
|
|
|
|
402
|
|
|
// back up the execution time limit |
403
|
|
|
$executionTime = intval(ini_get('max_execution_time')); |
404
|
|
|
// set execution time limit to unlimited |
405
|
|
|
set_time_limit(0); |
406
|
|
|
|
407
|
|
|
$count = 0; |
408
|
|
|
foreach ($fileIds as $fileId) { |
409
|
|
|
$fileNodes = $userHome->getById($fileId); |
410
|
|
|
if (count($fileNodes) > 0) { |
411
|
|
|
$file = $fileNodes[0]; |
412
|
|
|
if($debugOutput) { |
413
|
|
|
$before = memory_get_usage(true); |
414
|
|
|
} |
415
|
|
|
$this->update($file, $userId, $userHome); |
416
|
|
|
if($debugOutput) { |
417
|
|
|
$after = memory_get_usage(true); |
418
|
|
|
$diff = $after - $before; |
419
|
|
|
$afterFileSize = new FileSize($after); |
420
|
|
|
$diffFileSize = new FileSize($diff); |
421
|
|
|
$humanFilesizeAfter = $afterFileSize->getHumanReadable(); |
422
|
|
|
$humanFilesizeDiff = $diffFileSize->getHumanReadable(); |
423
|
|
|
$path = $file->getPath(); |
424
|
|
|
$debugOutput->writeln("\e[1m $count \e[0m $humanFilesizeAfter \e[1m $diff \e[0m ($humanFilesizeDiff) $path"); |
425
|
|
|
} |
426
|
|
|
$count++; |
427
|
|
|
} |
428
|
|
|
else { |
429
|
|
|
$this->logger->log("File with id $fileId not found for user $userId", 'warn'); |
430
|
|
|
} |
431
|
|
|
} |
432
|
|
|
|
433
|
|
|
// reset execution time limit |
434
|
|
|
set_time_limit($executionTime); |
435
|
|
|
|
436
|
|
|
return $count; |
437
|
|
|
} |
438
|
|
|
|
439
|
|
|
/** |
440
|
|
|
* Parse and get basic info about a file. The file does not have to be indexed in the database. |
441
|
|
|
* @param string $fileId |
442
|
|
|
* @param string $userId |
443
|
|
|
* @param Folder $userFolder |
444
|
|
|
*/ |
445
|
|
|
public function getFileInfo($fileId, $userId, $userFolder) { |
446
|
|
|
$fileNodes = $userFolder->getById($fileId); |
447
|
|
|
if (count($fileNodes) > 0) { |
448
|
|
|
$file = $fileNodes[0]; |
449
|
|
|
$metadata = $this->extractMetadata($file, $userFolder, $file->getPath()); |
450
|
|
|
return [ |
451
|
|
|
'title' => $metadata['title'], |
452
|
|
|
'artist' => $metadata['artist'], |
453
|
|
|
'in_library' => $this->pathIsUnderMusicFolder($file->getPath(), $userId, $userFolder) |
454
|
|
|
]; |
455
|
|
|
} |
456
|
|
|
return null; |
457
|
|
|
} |
458
|
|
|
|
459
|
|
|
/** |
460
|
|
|
* Wipe clean the music database of the given user, or all users |
461
|
|
|
* @param string $userId |
462
|
|
|
* @param boolean $allUsers |
463
|
|
|
*/ |
464
|
|
|
public function resetDb($userId, $allUsers = false) { |
465
|
|
|
if ($userId && $allUsers) { |
466
|
|
|
throw new InvalidArgumentException('userId should be null if allUsers targeted'); |
467
|
|
|
} |
468
|
|
|
|
469
|
|
|
$sqls = array( |
470
|
|
|
'DELETE FROM `*PREFIX*music_tracks`', |
471
|
|
|
'DELETE FROM `*PREFIX*music_albums`', |
472
|
|
|
'DELETE FROM `*PREFIX*music_artists`', |
473
|
|
|
'UPDATE *PREFIX*music_playlists SET track_ids=NULL', |
474
|
|
|
'DELETE FROM `*PREFIX*music_cache`' |
475
|
|
|
); |
476
|
|
|
|
477
|
|
|
foreach ($sqls as $sql) { |
478
|
|
|
$params = []; |
479
|
|
|
if (!$allUsers) { |
480
|
|
|
$sql .= ' WHERE `user_id` = ?'; |
481
|
|
|
$params[] = $userId; |
482
|
|
|
} |
483
|
|
|
$this->db->executeUpdate($sql, $params); |
484
|
|
|
} |
485
|
|
|
|
486
|
|
|
if ($allUsers) { |
487
|
|
|
$this->logger->log("Erased music databases of all users", 'info'); |
488
|
|
|
} else { |
489
|
|
|
$this->logger->log("Erased music database of user $userId", 'info'); |
490
|
|
|
} |
491
|
|
|
} |
492
|
|
|
|
493
|
|
|
/** |
494
|
|
|
* Update music path |
495
|
|
|
*/ |
496
|
|
|
public function updatePath($path, $userId) { |
497
|
|
|
// TODO currently this function is quite dumb |
498
|
|
|
// it just drops all entries of an user from the tables |
499
|
|
|
$this->logger->log("Changing music collection path of user $userId to $path", 'info'); |
500
|
|
|
$this->resetDb($userId); |
501
|
|
|
} |
502
|
|
|
|
503
|
|
|
public function findCovers() { |
504
|
|
|
$affectedUsers = $this->albumBusinessLayer->findCovers(); |
505
|
|
|
// scratch the cache for those users whose music collection was touched |
506
|
|
|
foreach ($affectedUsers as $user) { |
507
|
|
|
$this->cache->remove($user); |
508
|
|
|
$this->logger->log('album cover(s) were found for user '. $user , 'debug'); |
509
|
|
|
} |
510
|
|
|
return !empty($affectedUsers); |
511
|
|
|
} |
512
|
|
|
|
513
|
|
|
public function resolveUserFolder($userId) { |
514
|
|
|
$dir = '/' . $userId; |
515
|
|
|
$root = $this->rootFolder; |
516
|
|
|
|
517
|
|
|
// copy of getUserServer of server container |
518
|
|
|
$folder = null; |
519
|
|
|
|
520
|
|
View Code Duplication |
if (!$root->nodeExists($dir)) { |
521
|
|
|
$folder = $root->newFolder($dir); |
522
|
|
|
} else { |
523
|
|
|
$folder = $root->get($dir); |
524
|
|
|
} |
525
|
|
|
|
526
|
|
|
$dir = '/files'; |
527
|
|
View Code Duplication |
if (!$folder->nodeExists($dir)) { |
528
|
|
|
$folder = $folder->newFolder($dir); |
529
|
|
|
} else { |
530
|
|
|
$folder = $folder->get($dir); |
531
|
|
|
} |
532
|
|
|
|
533
|
|
|
return $folder; |
534
|
|
|
} |
535
|
|
|
|
536
|
|
|
private static function idsFromArray(array $arr) { |
537
|
|
|
return array_map(function($i) { return $i->getId(); }, $arr); |
538
|
|
|
} |
539
|
|
|
|
540
|
|
|
private static function startsWith($string, $potentialStart) { |
541
|
|
|
return substr($string, 0, strlen($potentialStart)) === $potentialStart; |
542
|
|
|
} |
543
|
|
|
|
544
|
|
|
private static function isNullOrEmpty($string) { |
545
|
|
|
return $string === null || $string === ''; |
546
|
|
|
} |
547
|
|
|
|
548
|
|
|
private static function normalizeOrdinal($ordinal) { |
549
|
|
|
// convert format '1/10' to '1' |
550
|
|
|
$tmp = explode('/', $ordinal); |
551
|
|
|
$ordinal = $tmp[0]; |
552
|
|
|
|
553
|
|
|
// check for numeric values - cast them to int and verify it's a natural number above 0 |
554
|
|
|
if(is_numeric($ordinal) && ((int)$ordinal) > 0) { |
555
|
|
|
$ordinal = (int)$ordinal; |
556
|
|
|
} else { |
557
|
|
|
$ordinal = null; |
558
|
|
|
} |
559
|
|
|
|
560
|
|
|
return $ordinal; |
561
|
|
|
} |
562
|
|
|
|
563
|
|
|
private static function parseFileName($fileName) { |
564
|
|
|
// If the file name starts e.g like "12 something" or "12. something" or "12 - something", |
565
|
|
|
// the preceeding number is extracted as track number. Everything after the optional track |
566
|
|
|
// number + delimiters part but before the file extension is extracted as title. |
567
|
|
|
// The file extension consists of a '.' followed by 1-4 "word characters". |
568
|
|
|
if(preg_match('/^((\d+)\s*[\s.-]\s*)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) { |
569
|
|
|
return ['track_number' => $matches[2], 'title' => $matches[3]]; |
570
|
|
|
} else { |
571
|
|
|
return ['track_number' => null, 'title' => $fileName]; |
572
|
|
|
} |
573
|
|
|
} |
574
|
|
|
|
575
|
|
|
private static function normalizeYear($date) { |
576
|
|
|
if(ctype_digit($date)) { |
577
|
|
|
return $date; // the date is a valid year as-is |
578
|
|
|
} else if(preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) { |
579
|
|
|
return $matches[1]; // year from ISO-formatted date yyyy-mm-dd |
580
|
|
|
} else { |
581
|
|
|
return null; |
582
|
|
|
} |
583
|
|
|
} |
584
|
|
|
|
585
|
|
|
/** |
586
|
|
|
* Loop through the tracks of an album and set the first track containing embedded cover art |
587
|
|
|
* as cover file for the album |
588
|
|
|
* @param int $albumId |
589
|
|
|
* @param string|null $userId name of user, deducted from $albumId if omitted |
590
|
|
|
* @param Folder|null $userFolder home folder of user, deducted from $userId if omitted |
591
|
|
|
*/ |
592
|
|
|
private function findEmbeddedCoverForAlbum($albumId, $userId=null, $userFolder=null) { |
593
|
|
|
if ($userId === null) { |
594
|
|
|
$userId = $this->albumBusinessLayer->findAlbumOwner($albumId); |
595
|
|
|
} |
596
|
|
|
if ($userFolder === null) { |
597
|
|
|
$userFolder = $this->resolveUserFolder($userId); |
598
|
|
|
} |
599
|
|
|
|
600
|
|
|
$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId); |
601
|
|
|
foreach ($tracks as $track) { |
602
|
|
|
$nodes = $userFolder->getById($track->getFileId()); |
603
|
|
|
if(count($nodes) > 0) { |
604
|
|
|
// parse the first valid node and check if it contains embedded cover art |
605
|
|
|
$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]); |
606
|
|
|
if ($image != null) { |
607
|
|
|
$this->albumBusinessLayer->setCover($track->getFileId(), $albumId); |
608
|
|
|
break; |
609
|
|
|
} |
610
|
|
|
} |
611
|
|
|
} |
612
|
|
|
} |
613
|
|
|
} |
614
|
|
|
|
This error could be the result of:
1. Missing dependencies
PHP Analyzer uses your
composer.json
file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects thecomposer.json
to be in the root folder of your repository.Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the
require
orrequire-dev
section?2. Missing use statement
PHP does not complain about undefined classes in
ìnstanceof
checks. For example, the following PHP code will work perfectly fine:If you have not tested against this specific condition, such errors might go unnoticed.