MediaSyncService   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 283
Duplicated Lines 0 %

Test Coverage

Coverage 86.36%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 32
eloc 117
c 2
b 1
f 0
dl 0
loc 283
ccs 95
cts 110
cp 0.8636
rs 9.84

13 Methods

Rating   Name   Duplication   Size   Complexity  
B sync() 0 51 7
A setSystemRequirements() 0 8 3
A handleDeletedFileRecord() 0 9 2
A setTags() 0 7 3
A handleDeletedDirectoryRecord() 0 8 2
A handleNewOrModifiedDirectoryRecord() 0 9 2
A __construct() 0 20 1
A gatherFiles() 0 10 1
A handleNewOrModifiedFileRecord() 0 11 2
A syncByWatchRecord() 0 4 2
A syncDirectoryRecord() 0 9 3
A syncFileRecord() 0 12 3
A tidy() 0 10 1
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());
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->fileSynchronizer->getSyncError() targeting App\Services\FileSynchronizer::getSyncError() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

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.

Loading history...
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()) {
0 ignored issues
show
introduced by
The method runningInConsole() does not exist on Illuminate\Container\Container. Are you sure you never get this type here, but always one of the subclasses? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

247
        if (!app()->/** @scrutinizer ignore-call */ runningInConsole()) {
Loading history...
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