1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace App\Services; |
4
|
|
|
|
5
|
|
|
use App\Console\Commands\SyncCommand; |
6
|
|
|
use App\Events\LibraryChanged; |
7
|
|
|
use App\Libraries\WatchRecord\WatchRecordInterface; |
8
|
|
|
use App\Models\Album; |
9
|
|
|
use App\Models\Artist; |
10
|
|
|
use App\Models\Setting; |
11
|
|
|
use App\Models\Song; |
12
|
|
|
use App\Repositories\AlbumRepository; |
13
|
|
|
use App\Repositories\ArtistRepository; |
14
|
|
|
use App\Repositories\SettingRepository; |
15
|
|
|
use App\Repositories\SongRepository; |
16
|
|
|
use Exception; |
17
|
|
|
use Psr\Log\LoggerInterface; |
18
|
|
|
use SplFileInfo; |
19
|
|
|
use Symfony\Component\Finder\Finder; |
20
|
|
|
|
21
|
|
|
class MediaSyncService |
22
|
|
|
{ |
23
|
|
|
/** |
24
|
|
|
* All applicable tags in a media file that we cater for. |
25
|
|
|
* Note that each isn't necessarily a valid ID3 tag name. |
26
|
|
|
* |
27
|
|
|
* @var array |
28
|
|
|
*/ |
29
|
|
|
private const APPLICABLE_TAGS = [ |
30
|
|
|
'artist', |
31
|
|
|
'album', |
32
|
|
|
'title', |
33
|
|
|
'length', |
34
|
|
|
'track', |
35
|
|
|
'disc', |
36
|
|
|
'lyrics', |
37
|
|
|
'cover', |
38
|
|
|
'mtime', |
39
|
|
|
'compilation', |
40
|
|
|
]; |
41
|
|
|
|
42
|
|
|
private $mediaMetadataService; |
43
|
|
|
private $songRepository; |
44
|
|
|
private $helperService; |
45
|
|
|
private $fileSynchronizer; |
46
|
|
|
private $finder; |
47
|
|
|
private $artistRepository; |
48
|
|
|
private $albumRepository; |
49
|
|
|
private $settingRepository; |
50
|
|
|
private $logger; |
51
|
|
|
|
52
|
132 |
|
public function __construct( |
53
|
|
|
MediaMetadataService $mediaMetadataService, |
54
|
|
|
SongRepository $songRepository, |
55
|
|
|
ArtistRepository $artistRepository, |
56
|
|
|
AlbumRepository $albumRepository, |
57
|
|
|
SettingRepository $settingRepository, |
58
|
|
|
HelperService $helperService, |
59
|
|
|
FileSynchronizer $fileSynchronizer, |
60
|
|
|
Finder $finder, |
61
|
|
|
LoggerInterface $logger |
62
|
|
|
) { |
63
|
132 |
|
$this->mediaMetadataService = $mediaMetadataService; |
64
|
132 |
|
$this->songRepository = $songRepository; |
65
|
132 |
|
$this->helperService = $helperService; |
66
|
132 |
|
$this->fileSynchronizer = $fileSynchronizer; |
67
|
132 |
|
$this->finder = $finder; |
68
|
132 |
|
$this->artistRepository = $artistRepository; |
69
|
132 |
|
$this->albumRepository = $albumRepository; |
70
|
132 |
|
$this->settingRepository = $settingRepository; |
71
|
132 |
|
$this->logger = $logger; |
72
|
132 |
|
} |
73
|
|
|
|
74
|
|
|
/** |
75
|
|
|
* Tags to be synced. |
76
|
|
|
* |
77
|
|
|
* @var array |
78
|
|
|
*/ |
79
|
|
|
protected $tags = []; |
80
|
|
|
|
81
|
|
|
/** |
82
|
|
|
* Sync the media. Oh sync the media. |
83
|
|
|
* |
84
|
|
|
* @param string[] $tags The tags to sync. |
85
|
|
|
* Only taken into account for existing records. |
86
|
|
|
* New records will have all tags synced in regardless. |
87
|
|
|
* @param bool $force Whether to force syncing even unchanged files |
88
|
|
|
* @param SyncCommand $syncCommand The SyncMedia command object, to log to console if executed by artisan. |
89
|
|
|
* |
90
|
|
|
* @throws Exception |
91
|
|
|
*/ |
92
|
6 |
|
public function sync( |
93
|
|
|
?string $mediaPath = null, |
94
|
|
|
array $tags = [], |
95
|
|
|
bool $force = false, |
96
|
|
|
?SyncCommand $syncCommand = null |
97
|
|
|
): void { |
98
|
6 |
|
$this->setSystemRequirements(); |
99
|
6 |
|
$this->setTags($tags); |
100
|
|
|
|
101
|
|
|
$results = [ |
102
|
6 |
|
'success' => [], |
103
|
|
|
'bad_files' => [], |
104
|
|
|
'unmodified' => [], |
105
|
|
|
]; |
106
|
|
|
|
107
|
6 |
|
$songPaths = $this->gatherFiles($mediaPath ?: Setting::get('media_path')); |
108
|
|
|
|
109
|
6 |
|
if ($syncCommand) { |
110
|
|
|
$syncCommand->createProgressBar(count($songPaths)); |
111
|
|
|
} |
112
|
|
|
|
113
|
6 |
|
foreach ($songPaths as $path) { |
114
|
6 |
|
$result = $this->fileSynchronizer->setFile($path)->sync($this->tags, $force); |
115
|
|
|
|
116
|
|
|
switch ($result) { |
117
|
6 |
|
case FileSynchronizer::SYNC_RESULT_SUCCESS: |
118
|
6 |
|
$results['success'][] = $path; |
119
|
6 |
|
break; |
120
|
6 |
|
case FileSynchronizer::SYNC_RESULT_UNMODIFIED: |
121
|
3 |
|
$results['unmodified'][] = $path; |
122
|
3 |
|
break; |
123
|
|
|
default: |
124
|
6 |
|
$results['bad_files'][] = $path; |
125
|
6 |
|
break; |
126
|
|
|
} |
127
|
|
|
|
128
|
6 |
|
if ($syncCommand) { |
129
|
|
|
$syncCommand->advanceProgressBar(); |
130
|
|
|
$syncCommand->logSyncStatusToConsole($path, $result, $this->fileSynchronizer->getSyncError()); |
|
|
|
|
131
|
|
|
} |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
// Delete non-existing songs. |
135
|
|
|
$hashes = array_map(function (string $path): string { |
136
|
6 |
|
return $this->helperService->getFileHash($path); |
137
|
6 |
|
}, array_merge($results['unmodified'], $results['success'])); |
138
|
|
|
|
139
|
6 |
|
Song::deleteWhereIDsNotIn($hashes); |
140
|
|
|
|
141
|
|
|
// Trigger LibraryChanged, so that TidyLibrary handler is fired to, erm, tidy our library. |
142
|
6 |
|
event(new LibraryChanged()); |
143
|
6 |
|
} |
144
|
|
|
|
145
|
|
|
/** |
146
|
|
|
* Gather all applicable files in a given directory. |
147
|
|
|
* |
148
|
|
|
* @param string $path The directory's full path |
149
|
|
|
* |
150
|
|
|
* @return SplFileInfo[] |
151
|
|
|
*/ |
152
|
6 |
|
public function gatherFiles(string $path): array |
153
|
|
|
{ |
154
|
6 |
|
return iterator_to_array( |
155
|
6 |
|
$this->finder->create() |
156
|
6 |
|
->ignoreUnreadableDirs() |
157
|
6 |
|
->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/phanan/koel/issues/450 |
158
|
6 |
|
->files() |
159
|
6 |
|
->followLinks() |
160
|
6 |
|
->name('/\.(mp3|ogg|m4a|flac)$/i') |
161
|
6 |
|
->in($path) |
162
|
|
|
); |
163
|
|
|
} |
164
|
|
|
|
165
|
|
|
/** |
166
|
|
|
* Sync media using a watch record. |
167
|
|
|
* |
168
|
|
|
* @throws Exception |
169
|
|
|
*/ |
170
|
3 |
|
public function syncByWatchRecord(WatchRecordInterface $record): void |
171
|
|
|
{ |
172
|
3 |
|
$this->logger->info("New watch record received: '{$record->getPath()}'"); |
173
|
3 |
|
$record->isFile() ? $this->syncFileRecord($record) : $this->syncDirectoryRecord($record); |
174
|
3 |
|
} |
175
|
|
|
|
176
|
|
|
/** |
177
|
|
|
* Sync a file's watch record. |
178
|
|
|
* |
179
|
|
|
* @throws Exception |
180
|
|
|
*/ |
181
|
2 |
|
private function syncFileRecord(WatchRecordInterface $record): void |
182
|
|
|
{ |
183
|
2 |
|
$path = $record->getPath(); |
184
|
2 |
|
$this->logger->info("'$path' is a file."); |
185
|
|
|
|
186
|
|
|
// If the file has been deleted... |
187
|
2 |
|
if ($record->isDeleted()) { |
188
|
1 |
|
$this->handleDeletedFileRecord($path); |
189
|
|
|
} |
190
|
|
|
// Otherwise, it's a new or changed file. Try to sync it in. |
191
|
1 |
|
elseif ($record->isNewOrModified()) { |
192
|
1 |
|
$this->handleNewOrModifiedFileRecord($path); |
193
|
|
|
} |
194
|
2 |
|
} |
195
|
|
|
|
196
|
|
|
/** |
197
|
|
|
* Sync a directory's watch record. |
198
|
|
|
*/ |
199
|
1 |
|
private function syncDirectoryRecord(WatchRecordInterface $record): void |
200
|
|
|
{ |
201
|
1 |
|
$path = $record->getPath(); |
202
|
1 |
|
$this->logger->info("'$path' is a directory."); |
203
|
|
|
|
204
|
1 |
|
if ($record->isDeleted()) { |
205
|
1 |
|
$this->handleDeletedDirectoryRecord($path); |
206
|
|
|
} elseif ($record->isNewOrModified()) { |
207
|
|
|
$this->handleNewOrModifiedDirectoryRecord($path); |
208
|
|
|
} |
209
|
1 |
|
} |
210
|
|
|
|
211
|
|
|
/** |
212
|
|
|
* Construct an array of tags to be synced into the database from an input array of tags. |
213
|
|
|
* If the input array is empty or contains only invalid items, we use all tags. |
214
|
|
|
* Otherwise, we only use the valid items in it. |
215
|
|
|
* |
216
|
|
|
* @param string[] $tags |
217
|
|
|
*/ |
218
|
6 |
|
public function setTags(array $tags = []): void |
219
|
|
|
{ |
220
|
6 |
|
$this->tags = array_intersect($tags, self::APPLICABLE_TAGS) ?: self::APPLICABLE_TAGS; |
221
|
|
|
|
222
|
|
|
// We always keep track of mtime. |
223
|
6 |
|
if (!in_array('mtime', $this->tags, true)) { |
224
|
2 |
|
$this->tags[] = 'mtime'; |
225
|
|
|
} |
226
|
6 |
|
} |
227
|
|
|
|
228
|
|
|
/** |
229
|
|
|
* Tidy up the library by deleting empty albums and artists. |
230
|
|
|
* |
231
|
|
|
* @throws Exception |
232
|
|
|
*/ |
233
|
7 |
|
public function tidy(): void |
234
|
|
|
{ |
235
|
7 |
|
$inUseAlbums = $this->albumRepository->getNonEmptyAlbumIds(); |
236
|
7 |
|
$inUseAlbums[] = Album::UNKNOWN_ID; |
237
|
7 |
|
Album::deleteWhereIDsNotIn($inUseAlbums); |
238
|
|
|
|
239
|
7 |
|
$inUseArtists = $this->artistRepository->getNonEmptyArtistIds(); |
240
|
7 |
|
$inUseArtists[] = Artist::UNKNOWN_ID; |
241
|
7 |
|
$inUseArtists[] = Artist::VARIOUS_ID; |
242
|
7 |
|
Artist::deleteWhereIDsNotIn(array_filter($inUseArtists)); |
243
|
7 |
|
} |
244
|
|
|
|
245
|
6 |
|
private function setSystemRequirements(): void |
246
|
|
|
{ |
247
|
6 |
|
if (!app()->runningInConsole()) { |
|
|
|
|
248
|
|
|
set_time_limit(config('koel.sync.timeout')); |
249
|
|
|
} |
250
|
|
|
|
251
|
6 |
|
if (config('koel.memory_limit')) { |
252
|
|
|
ini_set('memory_limit', config('koel.memory_limit').'M'); |
253
|
|
|
} |
254
|
6 |
|
} |
255
|
|
|
|
256
|
|
|
/** |
257
|
|
|
* @throws Exception |
258
|
|
|
*/ |
259
|
1 |
|
private function handleDeletedFileRecord(string $path): void |
260
|
|
|
{ |
261
|
1 |
|
if ($song = $this->songRepository->getOneByPath($path)) { |
262
|
1 |
|
$song->delete(); |
263
|
1 |
|
$this->logger->info("$path deleted."); |
264
|
|
|
|
265
|
1 |
|
event(new LibraryChanged()); |
266
|
|
|
} else { |
267
|
|
|
$this->logger->info("$path doesn't exist in our database--skipping."); |
268
|
|
|
} |
269
|
1 |
|
} |
270
|
|
|
|
271
|
1 |
|
private function handleNewOrModifiedFileRecord(string $path): void |
272
|
|
|
{ |
273
|
1 |
|
$result = $this->fileSynchronizer->setFile($path)->sync($this->tags); |
274
|
|
|
|
275
|
1 |
|
if ($result === FileSynchronizer::SYNC_RESULT_SUCCESS) { |
276
|
1 |
|
$this->logger->info("Synchronized $path"); |
277
|
|
|
} else { |
278
|
|
|
$this->logger->info("Failed to synchronized $path. Maybe an invalid file?"); |
279
|
|
|
} |
280
|
|
|
|
281
|
1 |
|
event(new LibraryChanged()); |
282
|
1 |
|
} |
283
|
|
|
|
284
|
1 |
|
private function handleDeletedDirectoryRecord(string $path): void |
285
|
|
|
{ |
286
|
1 |
|
if ($count = Song::inDirectory($path)->delete()) { |
287
|
1 |
|
$this->logger->info("Deleted $count song(s) under $path"); |
288
|
|
|
|
289
|
1 |
|
event(new LibraryChanged()); |
290
|
|
|
} else { |
291
|
|
|
$this->logger->info("$path is empty--no action needed."); |
292
|
|
|
} |
293
|
1 |
|
} |
294
|
|
|
|
295
|
|
|
private function handleNewOrModifiedDirectoryRecord(string $path): void |
296
|
|
|
{ |
297
|
|
|
foreach ($this->gatherFiles($path) as $file) { |
298
|
|
|
$this->fileSynchronizer->setFile($file)->sync($this->tags); |
299
|
|
|
} |
300
|
|
|
|
301
|
|
|
$this->logger->info("Synced all song(s) under $path"); |
302
|
|
|
|
303
|
|
|
event(new LibraryChanged()); |
304
|
|
|
} |
305
|
|
|
} |
306
|
|
|
|
This check looks for function or method calls that always return null and whose return value is used.
The method
getObject()
can return nothing but null, so it makes no sense to use the return value.The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.