Test Setup Failed
Push — master ( 0a8e7c...aa7267 )
by Phan
03:42
created

File::sync()   C

Complexity

Conditions 13
Paths 54

Size

Total Lines 68
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 68
rs 5.761
c 0
b 0
f 0
cc 13
eloc 31
nc 54
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Models;
4
5
use Exception;
6
use getID3;
7
use getid3_lib;
8
use Illuminate\Support\Facades\Log;
9
use Media;
10
use SplFileInfo;
11
use Symfony\Component\Finder\Finder;
12
13
class File
14
{
15
    /**
16
     * A MD5 hash of the file's path.
17
     * This value is unique, and can be used to query a Song record.
18
     *
19
     * @var string
20
     */
21
    protected $hash;
22
23
    /**
24
     * The file's last modified time.
25
     *
26
     * @var int
27
     */
28
    protected $mtime;
29
30
    /**
31
     * The file's path.
32
     *
33
     * @var string
34
     */
35
    protected $path;
36
37
    /**
38
     * The getID3 object, for ID3 tag reading.
39
     *
40
     * @var getID3
41
     */
42
    protected $getID3;
43
44
    /**
45
     * The SplFileInfo object of the file.
46
     *
47
     * @var SplFileInfo
48
     */
49
    protected $splFileInfo;
50
51
    /**
52
     * The song model that's associated with this file.
53
     *
54
     * @var Song
55
     */
56
    protected $song;
57
58
    /**
59
     * The last parsing error text, if any.
60
     *
61
     * @var string
62
     */
63
    protected $syncError;
64
65
    const SYNC_RESULT_SUCCESS = 1;
66
    const SYNC_RESULT_BAD_FILE = 2;
67
    const SYNC_RESULT_UNMODIFIED = 3;
68
69
    /**
70
     * Construct our File object.
71
     * Upon construction, we'll set the path, hash, and associated Song object (if any).
72
     *
73
     * @param string|SplFileInfo $path   Either the file's path, or a SplFileInfo object
74
     * @param getID3             $getID3 A getID3 object for DI (and better performance)
75
     */
76
    public function __construct($path, $getID3 = null)
77
    {
78
        $this->splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
79
        $this->setGetID3($getID3);
80
81
        // Workaround for #344, where getMTime() fails for certain files with Unicode names
82
        // on Windows.
83
        // Yes, beloved Windows.
84
        try {
85
            $this->mtime = $this->splFileInfo->getMTime();
86
        } catch (Exception $e) {
87
            // Not worth logging the error. Just use current stamp for mtime.
88
            $this->mtime = time();
89
        }
90
91
        $this->path = $this->splFileInfo->getPathname();
92
        $this->hash = self::getHash($this->path);
93
        $this->song = Song::find($this->hash);
94
        $this->syncError = '';
95
    }
96
97
    /**
98
     * Get all applicable ID3 info from the file.
99
     *
100
     * @return array
101
     */
102
    public function getInfo()
103
    {
104
        $info = $this->getID3->analyze($this->path);
105
106
        if (isset($info['error']) || !isset($info['playtime_seconds'])) {
107
            $this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found';
108
109
            return [];
110
        }
111
112
        // Copy the available tags over to comment.
113
        // This is a helper from getID3, though it doesn't really work well.
114
        // We'll still prefer getting ID3v2 tags directly later.
115
        // Read on.
116
        getid3_lib::CopyTagsToComments($info);
117
118
        $track = 0;
119
120
        // Apparently track number can be stored with different indices as the following.
121
        $trackIndices = [
122
            'comments.track',
123
            'comments.tracknumber',
124
            'comments.track_number',
125
        ];
126
127
        for ($i = 0; $i < count($trackIndices) && $track === 0; ++$i) {
128
            $track = array_get($info, $trackIndices[$i], [0])[0];
129
        }
130
131
        $props = [
132
            'artist' => '',
133
            'album' => '',
134
            'compilation' => false,
135
            'title' => '',
136
            'length' => $info['playtime_seconds'],
137
            'track' => (int) $track,
138
            'lyrics' => '',
139
            'cover' => array_get($info, 'comments.picture', [null])[0],
140
            'path' => $this->path,
141
            'mtime' => $this->mtime,
142
        ];
143
144
        if (!$comments = array_get($info, 'comments_html')) {
145
            return $props;
146
        }
147
148
        // We prefer id3v2 tags over others.
149
        if (!$artist = array_get($info, 'tags.id3v2.artist', [null])[0]) {
150
            $artist = array_get($comments, 'artist', [''])[0];
151
        }
152
153
        if (!$albumArtist = array_get($info, 'tags.id3v2.band', [null])[0]) {
154
            $albumArtist = array_get($comments, 'band', [''])[0];
155
        }
156
157
        if (!$album = array_get($info, 'tags.id3v2.album', [null])[0]) {
158
            $album = array_get($comments, 'album', [''])[0];
159
        }
160
161
        if (!$title = array_get($info, 'tags.id3v2.title', [null])[0]) {
162
            $title = array_get($comments, 'title', [''])[0];
163
        }
164
165
        if (!$lyrics = array_get($info, 'tags.id3v2.unsynchronised_lyric', [null])[0]) {
166
            $lyrics = array_get($comments, 'unsynchronised_lyric', [''])[0];
167
        }
168
169
        // Fixes #323, where tag names can be htmlentities()'ed
170
        $props['title'] = html_entity_decode(trim($title));
171
        $props['album'] = html_entity_decode(trim($album));
172
        $props['artist'] = html_entity_decode(trim($artist));
173
        $props['albumartist'] = html_entity_decode(trim($albumArtist));
174
        $props['lyrics'] = html_entity_decode(trim($lyrics));
175
176
        // A "compilation" property can be determined by:
177
        // - "part_of_a_compilation" tag (used by iTunes), or
178
        // - "albumartist" (used by non-retarded applications).
179
        $props['compilation'] = (bool) (
180
            array_get($comments, 'part_of_a_compilation', [false])[0] || $props['albumartist']
181
        );
182
183
        return $props;
184
    }
185
186
    /**
187
     * Sync the song with all available media info against the database.
188
     *
189
     * @param array $tags  The (selective) tags to sync (if the song exists)
190
     * @param bool  $force Whether to force syncing, even if the file is unchanged
191
     *
192
     * @return bool|Song A Song object on success,
193
     *                   true if file exists but is unmodified,
194
     *                   or false on an error.
195
     */
196
    public function sync($tags, $force = false)
197
    {
198
        // If the file is not new or changed and we're not forcing update, don't do anything.
199
        if (!$this->isNewOrChanged() && !$force) {
200
            return self::SYNC_RESULT_UNMODIFIED;
201
        }
202
203
        // If the file is invalid, don't do anything.
204
        if (!$info = $this->getInfo()) {
205
            return self::SYNC_RESULT_BAD_FILE;
206
        }
207
208
        // Fixes #366. If the file is new, we use all tags by simply setting $force to false.
209
        if ($this->isNew()) {
210
            $force = false;
211
        }
212
213
        if ($this->isChanged() || $force) {
214
            // This is a changed file, or the user is forcing updates.
215
            // In such a case, the user must have specified a list of tags to sync.
216
            // A sample command could be: ./artisan koel:sync --force --tags=artist,album,lyrics
217
            // We cater for these tags by removing those not specified.
218
219
            // There's a special case with 'album' though.
220
            // If 'compilation' tag is specified, 'album' must be counted in as well.
221
            // But if 'album' isn't specified, we don't want to update normal albums.
222
            // This variable is to keep track of this state.
223
            $changeCompilationAlbumOnly = false;
224
            if (in_array('compilation', $tags, true) && !in_array('album', $tags, true)) {
225
                $tags[] = 'album';
226
                $changeCompilationAlbumOnly = true;
227
            }
228
229
            $info = array_intersect_key($info, array_flip($tags));
230
231
            // If the "artist" tag is specified, use it.
232
            // Otherwise, re-use the existing model value.
233
            $artist = isset($info['artist']) ? Artist::get($info['artist']) : $this->song->album->artist;
234
235
            $isCompilation = (bool) array_get($info, 'compilation');
236
237
            // If the "album" tag is specified, use it.
238
            // Otherwise, re-use the existing model value.
239
            if (isset($info['album'])) {
240
                $album = $changeCompilationAlbumOnly
241
                    ? $this->song->album
242
                    : Album::get($artist, $info['album'], $isCompilation);
243
            } else {
244
                $album = $this->song->album;
245
            }
246
        } else {
247
            // The file is newly added.
248
            $isCompilation = (bool) array_get($info, 'compilation');
249
            $artist = Artist::get($info['artist']);
250
            $album = Album::get($artist, $info['album'], $isCompilation);
251
        }
252
253
        $album->has_cover || $this->generateAlbumCover($album, array_get($info, 'cover'));
254
255
        $info['album_id'] = $album->id;
256
        $info['artist_id'] = $artist->id;
257
258
        // Remove these values from the info array, so that we can just use the array as model's input data.
259
        array_forget($info, ['artist', 'albumartist', 'album', 'cover', 'compilation']);
260
        $this->song = Song::updateOrCreate(['id' => $this->hash], $info);
261
262
        return self::SYNC_RESULT_SUCCESS;
263
    }
264
265
    /**
266
     * Try to generate a cover for an album based on extracted data, or use the cover file under the directory.
267
     *
268
     * @param Album $album
269
     * @param $coverData
270
     */
271
    private function generateAlbumCover(Album $album, $coverData)
272
    {
273
        // If the album has no cover, we try to get the cover image from existing tag data
274
        if ($coverData) {
275
            try {
276
                $album->generateCover($coverData);
277
278
                return;
279
            } catch (Exception $e) {
280
                Log::error($e);
281
            }
282
        }
283
284
        // Or, if there's a cover image under the same directory, use it.
285
        if ($cover = $this->getCoverFileUnderSameDirectory()) {
286
            $album->copyCoverFile($cover);
287
        }
288
    }
289
290
    /**
291
     * Determine if the file is new (its Song record can't be found in the database).
292
     *
293
     * @return bool
294
     */
295
    public function isNew()
296
    {
297
        return !$this->song;
298
    }
299
300
    /**
301
     * Determine if the file is changed (its Song record is found, but the timestamp is different).
302
     *
303
     * @return bool
304
     */
305
    public function isChanged()
306
    {
307
        return !$this->isNew() && $this->song->mtime !== $this->mtime;
0 ignored issues
show
Documentation introduced by
The property mtime does not exist on object<App\Models\Song>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
308
    }
309
310
    /**
311
     * Determine if the file is new or changed.
312
     *
313
     * @return bool
314
     */
315
    public function isNewOrChanged()
316
    {
317
        return $this->isNew() || $this->isChanged();
318
    }
319
320
    /**
321
     * @return getID3
322
     */
323
    public function getGetID3()
324
    {
325
        return $this->getID3;
326
    }
327
328
    /**
329
     * Get the last parsing error's text.
330
     *
331
     * @return string
332
     */
333
    public function getSyncError()
334
    {
335
        return $this->syncError;
336
    }
337
338
    /**
339
     * @param getID3 $getID3
340
     */
341
    public function setGetID3($getID3 = null)
342
    {
343
        $this->getID3 = $getID3 ?: new getID3();
344
    }
345
346
    /**
347
     * @return string
348
     */
349
    public function getPath()
350
    {
351
        return $this->path;
352
    }
353
354
    /**
355
     * Issue #380.
356
     * Some albums have its own cover image under the same directory as cover|folder.jpg/png.
357
     * We'll check if such a cover file is found, and use it if positive.
358
     *
359
     * @throws \InvalidArgumentException
360
     *
361
     * @return string|false The cover file's full path, or false if none found
362
     */
363
    private function getCoverFileUnderSameDirectory()
364
    {
365
        // As directory scanning can be expensive, we cache and reuse the result.
366
        $cacheKey = md5($this->path.'_cover');
367
368
        if (!is_null($cover = cache($cacheKey))) {
369
            return $cover;
370
        }
371
372
        $matches = array_keys(iterator_to_array(
373
            Finder::create()
374
                ->depth(0)
375
                ->ignoreUnreadableDirs()
376
                ->files()
377
                ->followLinks()
378
                ->name('/(cov|fold)er\.(jpe?g|png)$/i')
379
                ->in(dirname($this->path))
380
        ));
381
382
        $cover = $matches ? $matches[0] : false;
383
        // Even if a file is found, make sure it's a real image.
384
        if ($cover && exif_imagetype($cover) === false) {
385
            $cover = false;
386
        }
387
388
        cache([$cacheKey => $cover], 24 * 60);
389
390
        return $cover;
391
    }
392
393
    /**
394
     * Get a unique hash from a file path.
395
     *
396
     * @param string $path
397
     *
398
     * @return string
399
     */
400
    public static function getHash($path)
401
    {
402
        return md5(config('app.key').$path);
403
    }
404
}
405