Completed
Push — master ( 199635...738ba0 )
by Dorian
01:26
created

YouTube::removeFolders()   C

Complexity

Conditions 7
Paths 11

Size

Total Lines 82
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 82
rs 6.5492
c 0
b 0
f 0
cc 7
eloc 52
nc 11
nop 2

How to fix   Long Method   

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 declare(strict_types=1);
2
3
namespace App\Platform\YouTube;
4
5
use App\Domain\Collection;
6
use App\Domain\Content;
7
use App\Domain\Path;
8
use App\Domain\PathPart;
9
use App\Platform\Platform;
10
use App\UI\UserInterface;
11
use App\YoutubeDl\Exception as YoutubeDlException;
12
use App\YoutubeDl\YoutubeDl;
13
use Symfony\Component\Filesystem\Filesystem;
14
use Symfony\Component\Finder\Finder;
15
16
final class YouTube implements Platform
17
{
18
    // See https://stackoverflow.com/a/37704433/389519
19
    private const YOUTUBE_URL_REGEX = <<<REGEX
20
/\b((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?/i
21
REGEX;
22
    private const YOUTUBE_URL_REGEX_MATCHES_ID_INDEX = 5;
23
    private const YOUTUBE_URL_PREFIX = 'https://www.youtube.com/watch?v=';
24
25
    /** @var \App\UI\UserInterface */
26
    private $ui;
27
28
    /** @var array */
29
    private $options;
30
31
    /** @var bool */
32
    private $dryRun;
33
34
    /**
35
     * {@inheritdoc}
36
     * @throws \RuntimeException
37
     * @throws \Symfony\Component\Filesystem\Exception\IOException
38
     */
39
    public function __construct(UserInterface $ui, array $options, bool $dryRun = false)
40
    {
41
        $this->ui = $ui;
42
        $this->options = $options;
43
        $this->dryRun = $dryRun;
44
    }
45
46
    /**
47
     * @param \App\Domain\Content[]|\App\Domain\Collection $contents
48
     * @param \App\Domain\PathPart $rootPathPart
49
     * @throws \RuntimeException
50
     */
51
    public function synchronizeContents(Collection $contents, PathPart $rootPathPart): void
52
    {
53
        if ($contents->isEmpty()) {
54
            return;
55
        }
56
57
        $platformPathPart = new PathPart($this->options['path_part']);
58
        $downloadPath = new Path([$rootPathPart, $platformPathPart]);
59
60
        // Add the platform path part and get a collection of downloads
61
        $downloads = new Collection();
62
        foreach ($contents as $content) {
63
            $content->getPath()->add($platformPathPart);
64
65
            foreach ($this->extractDownloads($content) as $download) {
66
                $downloads->add($download);
67
            }
68
        }
69
70
        $this->cleanFilesystem($downloads, $downloadPath);
71
        $this->download($downloads, $downloadPath);
72
    }
73
74
    /**
75
     * @param \App\Domain\Content $content
76
     *
77
     * @return \App\Platform\YouTube\Download[]|\App\Domain\Collection
78
     */
79
    private function extractDownloads(Content $content): Collection
80
    {
81
        $downloads = new Collection();
82
83
        if (preg_match_all(static::YOUTUBE_URL_REGEX, $content->getData(), $youtubeUrls)) {
84
            foreach ((array) $youtubeUrls[static::YOUTUBE_URL_REGEX_MATCHES_ID_INDEX] as $youtubeId) {
85
                foreach (array_keys($this->options['youtube_dl']['options']) as $videoFileType) {
86
                    $downloads->add(
87
                        new Download(
88
                            $content->getPath(),
89
                            $youtubeId,
90
                            $videoFileType,
91
                            $this->options['patterns']['extensions'][$videoFileType]
92
                        )
93
                    );
94
                }
95
            }
96
        }
97
98
        return $downloads;
99
    }
100
101
    /**
102
     * @param \App\Platform\YouTube\Download $download
103
     *
104
     * @return \Symfony\Component\Finder\Finder|\SplFileInfo[]
105
     * @throws \InvalidArgumentException
106
     */
107
    private function getDownloadFolderFinder(Download $download): Finder
108
    {
109
        $placeholders = [
110
            '%video_id%' => $download->getVideoId(),
111
            '%file_extension%' => $download->getFileExtension(),
112
        ];
113
114
        $downloadPathPart = new PathPart([
115
            'path' => (string) $download->getPath(),
116
            'priority' => 0,
117
        ]);
118
        $folderPathPart = new PathPart([
119
            'path' => $this->options['patterns']['folder'],
120
            'priority' => 1,
121
            'substitutions' => $placeholders,
122
        ]);
123
124
        return (new Finder())
125
            ->files()
126
            ->depth('== 0')
127
            ->in((string) new Path([$downloadPathPart, $folderPathPart]))
128
            ->name(
129
                str_replace(
130
                    array_keys($placeholders),
131
                    array_values($placeholders),
132
                    $this->options['patterns']['filename']
133
                )
134
            );
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     * @param \App\Platform\YouTube\Download[]|\App\Domain\Collection $downloads
140
     *
141
     * @throws \RuntimeException
142
     */
143
    private function cleanFilesystem(Collection $downloads, Path $downloadPath): void
144
    {
145
        $this->ui->write(
146
            sprintf(
147
                'Synchronize the <info>%s</info> folder with the downloaded contents... ',
148
                (string) $downloadPath
149
            )
150
        );
151
152
        $completedDownloadsFolders = new Collection();
153
        foreach ($downloads as $download) {
154
            try {
155
                foreach ($this->getDownloadFolderFinder($download) as $downloadFolder) {
156
                    $parentFolder = $downloadFolder->getPathInfo();
157
158
                    // Using a key ensures there's no duplicates
159
                    $completedDownloadsFolders->set($parentFolder->getRealPath(), $parentFolder);
160
                }
161
            } catch (\InvalidArgumentException $e) {
162
            }
163
        }
164
165
        /** @var \SplFileInfo[]|\App\Domain\Collection $foldersToRemove */
166
        $foldersToRemove = new Collection();
167
        try {
168
            $allFolders = (new Finder())
169
                ->directories()
170
                ->in((string) $downloadPath)
171
                ->sort(function (\SplFileInfo $fileInfoA, \SplFileInfo $fileInfoB) {
172
                    // Sort the result by folder depth
173
                    $a = substr_count($fileInfoA->getRealPath(), DIRECTORY_SEPARATOR);
174
                    $b = substr_count($fileInfoB->getRealPath(), DIRECTORY_SEPARATOR);
175
176
                    return $a <=> $b;
177
                });
178
179
            foreach ($allFolders->getIterator() as $folder) {
180
                if (!$this->isFolderInCollection($folder, $foldersToRemove, true, $downloadPath) &&
181
                    !$this->isFolderInCollection($folder, $completedDownloadsFolders)
182
                ) {
183
                    $foldersToRemove->add($folder);
184
                }
185
            }
186
        } catch (\LogicException $e) {
187
            // Here we know that the download folder will exist.
188
        }
189
190
        $hasRemovedFolders = $this->removeFolders($foldersToRemove, $downloadPath);
191
192
        $newLine = $hasRemovedFolders ? PHP_EOL : '';
193
        $this->ui->writeln($newLine.'<info>Done.</info>'.$newLine);
194
    }
195
196
    /**
197
     * Checks if a folder (or one of its parent, up to the $limit parameter) is found in the collection of folders.
198
     *
199
     * @param \SplFileInfo $folderToSearchFor
200
     * @param \SplFileInfo[]|\App\Domain\Collection $folders
201
     * @param bool $loopOverParentsFolders
202
     * @param Path $untilPath
203
     *
204
     * @return bool
205
     * @throws \RuntimeException
206
     */
207
    public function isFolderInCollection(
208
        \SplFileInfo $folderToSearchFor,
209
        Collection $folders,
210
        bool $loopOverParentsFolders = false,
211
        ?Path $untilPath = null
212
    ): bool {
213
        foreach ($folders as $folder) {
214
            do {
215
                // This allows to match "/root/path" in "/root/path" or "/root/path/sub_path"
216
                if (0 === strpos($folder->getRealPath(), $folderToSearchFor->getRealPath())) {
217
                    return true;
218
                }
219
220
                if (!$loopOverParentsFolders) {
221
                    break;
222
                }
223
                if (null === $untilPath) {
224
                    throw new \RuntimeException(
225
                        'If $loopOverParentsFolders is set to true, then $untilPath must be provided.'.
226
                        'Otherwise you will experience infinite loops.'
227
                    );
228
                }
229
230
                $folderToSearchFor = $folderToSearchFor->getPathInfo();
231
232
            } while ($folderToSearchFor->getRealPath() !== (string) $untilPath);
233
        }
234
235
        return false;
236
    }
237
238
    /**
239
     * @param \SplFileInfo[]|\App\Domain\Collection $foldersToRemove
240
     * @param \App\Domain\Path $downloadPath
241
     *
242
     * @return bool Whether folders were removed or not.
243
     */
244
    private function removeFolders(Collection $foldersToRemove, Path $downloadPath): bool
245
    {
246
        $nbFoldersToRemove = $foldersToRemove->count();
247
        if (empty($nbFoldersToRemove)) {
248
            return false;
249
        }
250
251
        $this->ui->writeln(PHP_EOL);
252
253
        $confirmationDefault = true;
254
255
        // If there's less than 10 folders, we can display them
256
        if ($nbFoldersToRemove <= 10) {
257
            $this->ui->writeln(
258
                sprintf(
259
                    '%sThe script is about to remove the following folders from <info>%s</info>:',
260
                    $this->ui->indent(),
261
                    (string) $downloadPath
262
                )
263
            );
264
            $this->ui->listing(
265
                $foldersToRemove
266
                    ->map(function (\SplFileInfo $folder) use ($downloadPath) {
267
                        return sprintf(
268
                            '<info>%s</info>',
269
                            str_replace((string) $downloadPath.DIRECTORY_SEPARATOR, '', $folder->getRealPath())
270
                        );
271
                    })
272
                    ->toArray(),
273
                3
274
            );
275
        } else {
276
            $confirmationDefault = false;
277
278
            $this->ui->write(
279
                sprintf(
280
                    '%sThe script is about to remove <question> %s </question> folders from <info>%s</info>. ',
281
                    $this->ui->indent(),
282
                    $nbFoldersToRemove,
283
                    (string) $downloadPath
284
                )
285
            );
286
        }
287
288
        $foldersWereRemoved = false;
289
290
        $this->ui->write($this->ui->indent());
291
        if ($this->skip() || !$this->ui->confirm($confirmationDefault)) {
292
            return $foldersWereRemoved;
293
        }
294
295
        $errors = [];
296
        foreach ($foldersToRemove as $folderToRemove) {
297
            $relativeFolderPath = $folderToRemove->getRelativePathname();
298
299
            try {
300
                (new Filesystem())->remove($folderToRemove->getRealPath());
301
302
                $foldersWereRemoved = true;
303
304
                $this->ui->writeln(
305
                    sprintf(
306
                        '%s* The folder <info>%s</info> has been removed.',
307
                        $this->ui->indent(2),
308
                        $relativeFolderPath
309
                    )
310
                );
311
            } catch (\Exception $e) {
312
                $this->ui->logError(
313
                    sprintf(
314
                        '%s* <error>The folder %s could not be removed.</error>',
315
                        $this->ui->indent(2),
316
                        $relativeFolderPath
317
                    ),
318
                    $errors
319
                );
320
            }
321
        }
322
        $this->ui->displayErrors($errors, 'the removal of folders', 'info', 1);
323
324
        return $foldersWereRemoved;
325
    }
326
327
    /**
328
     * @param \App\Platform\YouTube\Download[]|\App\Domain\Collection $downloads
329
     * @param \App\Domain\Path $downloadPath
330
     *
331
     * @throws \RuntimeException
332
     */
333
    private function download(Collection $downloads, Path $downloadPath)
334
    {
335
        // Try to create the downloads directory... 'cause if it fails, nothing will work.
336
        (new Filesystem())->mkdir((string) $downloadPath);
337
338
        $this->ui->writeln('Download files from YouTube... '.PHP_EOL);
339
340
        // Filter out downloads that have already been downloaded
341
        /** @var \App\Platform\YouTube\Download[]|\App\Domain\Collection $downloads */
342
        $downloads = $downloads->filter(
343
            function (Download $download) {
344
                $shouldBeDownloaded = true;
345
                try {
346
                    if ($this->getDownloadFolderFinder($download)->hasResults()) {
347
                        $shouldBeDownloaded = false;
348
                    }
349
                } catch (\InvalidArgumentException $e) {
350
                    // Here we know that the download folder will exist.
351
                }
352
353
                return $shouldBeDownloaded;
354
            }
355
        );
356
357
        if ($downloads->isEmpty()) {
358
            $this->ui->writeln($this->ui->indent().'<comment>Nothing to download.</comment>'.PHP_EOL);
359
360
            return;
361
        }
362
363
        $this->ui->writeln(
364
            sprintf(
365
                '%sThe script is about to download <question> %s </question> files into <info>%s</info>. '.PHP_EOL,
366
                $this->ui->indent(),
367
                $downloads->count(),
368
                (string) $downloadPath
369
            )
370
        );
371
372
        $this->ui->write($this->ui->indent());
373
        if ($this->skip() || !$this->ui->confirm()) {
374
            $this->ui->writeln(PHP_EOL.'<info>Done.</info>'.PHP_EOL);
375
376
            return;
377
        }
378
379
        $errors = [];
380
        foreach ($downloads as $download) {
381
            $this->ui->write(
382
                sprintf(
383
                    '%s* [<comment>%s</comment>][<comment>%s</comment>] Download the %s file in <info>%s</info>... ',
384
                    $this->ui->indent(2),
385
                    $download->getVideoId(),
386
                    $download->getFileType(),
387
                    $download->getFileExtension(),
388
                    $download->getPath()
389
                )
390
            );
391
392
            $options = $this->options['youtube_dl']['options'][$download->getFileType()];
393
            $nbAttempts = \count($options);
394
395
            $attempt = 0;
396
            while (true) {
397
                try {
398
                    $this->doDownload($download, $options[$attempt]);
399
400
                    $this->ui->writeln('<info>Done.</info>');
401
                    break;
402
403
                } catch (YoutubeDlException\ChannelRemovedByUserException
404
                    | YoutubeDlException\VideoBlockedByCopyrightException
405
                    | YoutubeDlException\VideoRemovedByUserException
406
                    | YoutubeDlException\VideoUnavailableException
407
                $e) {
408
                    $this->ui->logError($e->getMessage(), $errors);
409
                    break;
410
411
                // These are (supposedly) connection/download errors, so we try again
412
                } catch (\Exception $e) {
413
                    $attempt++;
414
415
                    // Maximum number of attempts reached, move along...
416
                    if ($attempt === $nbAttempts) {
417
                        $this->ui->logError($e->getMessage(), $errors);
418
                        break;
419
                    }
420
                }
421
            }
422
        }
423
        $this->ui->displayErrors($errors, 'download of files', 'error', 1);
424
425
        $this->ui->writeln(PHP_EOL.'<info>Done.</info>'.PHP_EOL);
426
    }
427
428
    /**
429
     * @param \App\Platform\YouTube\Download $download
430
     * @param array $youtubeDlOptions
431
     *
432
     * @throws \Exception
433
     */
434
    private function doDownload(Download $download, array $youtubeDlOptions)
435
    {
436
        $dl = new YoutubeDl($youtubeDlOptions);
437
        $dl->setDownloadPath((string) $download->getPath());
438
439
        try {
440
            (new Filesystem())->mkdir((string) $download->getPath());
441
            $dl->download(static::YOUTUBE_URL_PREFIX.$download->getVideoId());
442
        } catch (\Exception $e) {
443
444
            // Add more custom exceptions than those already provided by YoutubeDl
445
            if (preg_match('/this video is unavailable/i', $e->getMessage())) {
446
                throw new YoutubeDlException\VideoUnavailableException(
447
                    sprintf('The video %s is unavailable.', $download->getVideoId()), 0, $e
448
                );
449
            }
450
            if (preg_match('/this video has been removed by the user/i', $e->getMessage())) {
451
                throw new YoutubeDlException\VideoRemovedByUserException(
452
                    sprintf('The video %s has been removed by its user.', $download->getVideoId()), 0, $e
453
                );
454
            }
455
            if (preg_match('/the uploader has closed their YouTube account/i', $e->getMessage())) {
456
                throw new YoutubeDlException\ChannelRemovedByUserException(
457
                    sprintf('The channel that published the video %s has been removed.', $download->getVideoId()), 0, $e
458
                );
459
            }
460
            if (preg_match('/who has blocked it on copyright grounds/i', $e->getMessage())) {
461
                throw new YoutubeDlException\VideoBlockedByCopyrightException(
462
                    sprintf('The video %s has been block for copyright infringement.', $download->getVideoId()), 0, $e
463
                );
464
            }
465
466
            throw $e;
467
        }
468
    }
469
470
    /**
471
     * @return bool
472
     */
473
    private function skip(): bool
474
    {
475
        if ($this->dryRun) {
476
            $this->ui->writeln('<info>[DRY-RUN]</info> Not doing anything...'.PHP_EOL);
477
478
            return true;
479
        }
480
481
        return false;
482
    }
483
}
484