xref: /webtrees/app/Services/MediaFileService.php (revision 449b311ecf65f677a2595e1e29f712d11ef22f34)
1d4265d07SGreg Roach<?php
2d4265d07SGreg Roach
3d4265d07SGreg Roach/**
4d4265d07SGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
6d4265d07SGreg Roach * This program is free software: you can redistribute it and/or modify
7d4265d07SGreg Roach * it under the terms of the GNU General Public License as published by
8d4265d07SGreg Roach * the Free Software Foundation, either version 3 of the License, or
9d4265d07SGreg Roach * (at your option) any later version.
10d4265d07SGreg Roach * This program is distributed in the hope that it will be useful,
11d4265d07SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
12d4265d07SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13d4265d07SGreg Roach * GNU General Public License for more details.
14d4265d07SGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
16d4265d07SGreg Roach */
17d4265d07SGreg Roach
18d4265d07SGreg Roachdeclare(strict_types=1);
19d4265d07SGreg Roach
20d4265d07SGreg Roachnamespace Fisharebest\Webtrees\Services;
21d4265d07SGreg Roach
226f4ec3caSGreg Roachuse Fisharebest\Webtrees\DB;
23b5c53c7fSGreg Roachuse Fisharebest\Webtrees\Exceptions\FileUploadException;
24d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages;
25d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N;
2603f0aef8SGreg Roachuse Fisharebest\Webtrees\Registry;
27d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree;
28b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator;
2913aa75d8SGreg Roachuse Illuminate\Database\Query\Expression;
30c6bad570SGreg Roachuse Illuminate\Database\Query\JoinClause;
3113aa75d8SGreg Roachuse Illuminate\Support\Collection;
32d4265d07SGreg Roachuse InvalidArgumentException;
33f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException;
34f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator;
35782714c2SGreg Roachuse League\Flysystem\FilesystemReader;
36f7cf8a15SGreg Roachuse League\Flysystem\StorageAttributes;
37d4265d07SGreg Roachuse Psr\Http\Message\ServerRequestInterface;
38d4265d07SGreg Roachuse RuntimeException;
39d4265d07SGreg Roach
40d4265d07SGreg Roachuse function array_combine;
41d4265d07SGreg Roachuse function array_diff;
42f9b64f46SGreg Roachuse function array_intersect;
4313aa75d8SGreg Roachuse function dirname;
44f9b64f46SGreg Roachuse function explode;
45d501c45dSGreg Roachuse function ini_get;
46d4265d07SGreg Roachuse function intdiv;
4781aa9d16SGreg Roachuse function is_float;
48d501c45dSGreg Roachuse function min;
49d4265d07SGreg Roachuse function pathinfo;
50d4265d07SGreg Roachuse function sha1;
51d4265d07SGreg Roachuse function sort;
52dec352c1SGreg Roachuse function str_contains;
530ea5fc1cSGreg Roachuse function strtoupper;
5445fc2659SGreg Roachuse function strtr;
55d501c45dSGreg Roachuse function substr;
56d4265d07SGreg Roachuse function trim;
5746b03695SGreg Roach
58d4265d07SGreg Roachuse const PATHINFO_EXTENSION;
5981aa9d16SGreg Roachuse const PHP_INT_MAX;
60d4265d07SGreg Roachuse const UPLOAD_ERR_OK;
61d4265d07SGreg Roach
62d4265d07SGreg Roach/**
63d4265d07SGreg Roach * Managing media files.
64d4265d07SGreg Roach */
65d4265d07SGreg Roachclass MediaFileService
66d4265d07SGreg Roach{
67c6bad570SGreg Roach    private const IGNORE_FOLDERS = [
68c6bad570SGreg Roach        // Old versions of webtrees
69c6bad570SGreg Roach        'thumbs',
70c6bad570SGreg Roach        'watermarks',
71c6bad570SGreg Roach        // Windows
72c6bad570SGreg Roach        'Thumbs.db',
73c6bad570SGreg Roach        // Synology
74c6bad570SGreg Roach        '@eaDir',
75c6bad570SGreg Roach        // QNAP,
76c6bad570SGreg Roach        '.@__thumb',
772c7d07c0SGreg Roach        // WebDAV,
782c7d07c0SGreg Roach        '_DAV',
79c6bad570SGreg Roach    ];
80c6bad570SGreg Roach
81d4265d07SGreg Roach    /**
82d4265d07SGreg Roach     * What is the largest file a user may upload?
83d4265d07SGreg Roach     */
84d4265d07SGreg Roach    public function maxUploadFilesize(): string
85d4265d07SGreg Roach    {
86d8809d62SGreg Roach        $sizePostMax   = $this->parseIniFileSize((string) ini_get('post_max_size'));
87d8809d62SGreg Roach        $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize'));
88d501c45dSGreg Roach
89d501c45dSGreg Roach        $bytes = min($sizePostMax, $sizeUploadMax);
90d4265d07SGreg Roach        $kb    = intdiv($bytes + 1023, 1024);
91d4265d07SGreg Roach
92d4265d07SGreg Roach        return I18N::translate('%s KB', I18N::number($kb));
93d4265d07SGreg Roach    }
94d4265d07SGreg Roach
95d4265d07SGreg Roach    /**
96d501c45dSGreg Roach     * Returns the given size from an ini value in bytes.
97d501c45dSGreg Roach     *
98284014f8SGreg Roach     * @param string $size
99d501c45dSGreg Roach     *
100d501c45dSGreg Roach     * @return int
101d501c45dSGreg Roach     */
102284014f8SGreg Roach    private function parseIniFileSize(string $size): int
103d501c45dSGreg Roach    {
104d501c45dSGreg Roach        $number = (int) $size;
105d501c45dSGreg Roach
10681aa9d16SGreg Roach        $units = [
10781aa9d16SGreg Roach            'g' => 1073741824,
10881aa9d16SGreg Roach            'G' => 1073741824,
10981aa9d16SGreg Roach            'm' => 1048576,
11081aa9d16SGreg Roach            'M' => 1048576,
11181aa9d16SGreg Roach            'k' => 1024,
11281aa9d16SGreg Roach            'K' => 1024,
11381aa9d16SGreg Roach        ];
11481aa9d16SGreg Roach
11581aa9d16SGreg Roach        $number *= $units[substr($size, -1)] ?? 1;
11681aa9d16SGreg Roach
11781aa9d16SGreg Roach        if (is_float($number)) {
11881aa9d16SGreg Roach            // Probably a 32bit version of PHP, with an INI setting >= 2GB
11981aa9d16SGreg Roach            return PHP_INT_MAX;
120d501c45dSGreg Roach        }
12181aa9d16SGreg Roach
12281aa9d16SGreg Roach        return $number;
123d501c45dSGreg Roach    }
124d501c45dSGreg Roach
125d501c45dSGreg Roach    /**
126d4265d07SGreg Roach     * A list of media files not already linked to a media object.
127d4265d07SGreg Roach     *
128d4265d07SGreg Roach     * @param Tree $tree
129d4265d07SGreg Roach     *
130bfe98399SGreg Roach     * @return array<string>
131d4265d07SGreg Roach     */
1329458f20aSGreg Roach    public function unusedFiles(Tree $tree): array
133d4265d07SGreg Roach    {
134d4265d07SGreg Roach        $used_files = DB::table('media_file')
135d4265d07SGreg Roach            ->where('m_file', '=', $tree->id())
136d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
137d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
138d4265d07SGreg Roach            ->pluck('multimedia_file_refn')
139d4265d07SGreg Roach            ->all();
140d4265d07SGreg Roach
1419458f20aSGreg Roach        $media_filesystem = $tree->mediaFilesystem();
142782714c2SGreg Roach        $disk_files       = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all();
143d4265d07SGreg Roach        $unused_files     = array_diff($disk_files, $used_files);
144d4265d07SGreg Roach
145d4265d07SGreg Roach        sort($unused_files);
146d4265d07SGreg Roach
147d4265d07SGreg Roach        return array_combine($unused_files, $unused_files);
148d4265d07SGreg Roach    }
149d4265d07SGreg Roach
150d4265d07SGreg Roach    /**
151d4265d07SGreg Roach     * Store an uploaded file (or URL), either to be added to a media object
152d4265d07SGreg Roach     * or to create a media object.
153d4265d07SGreg Roach     *
154d4265d07SGreg Roach     * @param ServerRequestInterface $request
155d4265d07SGreg Roach     *
156d4265d07SGreg Roach     * @return string The value to be stored in the 'FILE' field of the media object.
157f7cf8a15SGreg Roach     * @throws FilesystemException
158d4265d07SGreg Roach     */
159d4265d07SGreg Roach    public function uploadFile(ServerRequestInterface $request): string
160d4265d07SGreg Roach    {
161b55cbc6bSGreg Roach        $tree          = Validator::attributes($request)->tree();
162748dbe15SGreg Roach        $file_location = Validator::parsedBody($request)->string('file_location');
163d4265d07SGreg Roach
164d4265d07SGreg Roach        switch ($file_location) {
165d4265d07SGreg Roach            case 'url':
166748dbe15SGreg Roach                $remote = Validator::parsedBody($request)->string('remote');
167d4265d07SGreg Roach
168dec352c1SGreg Roach                if (str_contains($remote, '://')) {
169d4265d07SGreg Roach                    return $remote;
170d4265d07SGreg Roach                }
171d4265d07SGreg Roach
172d4265d07SGreg Roach                return '';
173d4265d07SGreg Roach
174d4265d07SGreg Roach            case 'unused':
175748dbe15SGreg Roach                $unused = Validator::parsedBody($request)->string('unused');
176d4265d07SGreg Roach
1779458f20aSGreg Roach                if ($tree->mediaFilesystem()->fileExists($unused)) {
178d4265d07SGreg Roach                    return $unused;
179d4265d07SGreg Roach                }
180d4265d07SGreg Roach
181d4265d07SGreg Roach                return '';
182d4265d07SGreg Roach
183d4265d07SGreg Roach            case 'upload':
184748dbe15SGreg Roach                $folder   = Validator::parsedBody($request)->string('folder');
185748dbe15SGreg Roach                $auto     = Validator::parsedBody($request)->string('auto');
186748dbe15SGreg Roach                $new_file = Validator::parsedBody($request)->string('new_file');
187d4265d07SGreg Roach
188b5c53c7fSGreg Roach                $uploaded_file = $request->getUploadedFiles()['file'] ?? null;
189b5c53c7fSGreg Roach
190d4265d07SGreg Roach                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
191b5c53c7fSGreg Roach                    throw new FileUploadException($uploaded_file);
192d4265d07SGreg Roach                }
193d4265d07SGreg Roach
194d4265d07SGreg Roach                // The filename
195dec352c1SGreg Roach                $new_file = strtr($new_file, ['\\' => '/']);
196dec352c1SGreg Roach                if ($new_file !== '' && !str_contains($new_file, '/')) {
197d4265d07SGreg Roach                    $file = $new_file;
198d4265d07SGreg Roach                } else {
199d4265d07SGreg Roach                    $file = $uploaded_file->getClientFilename();
200d4265d07SGreg Roach                }
201d4265d07SGreg Roach
202d4265d07SGreg Roach                // The folder
203dec352c1SGreg Roach                $folder = strtr($folder, ['\\' => '/']);
204d4265d07SGreg Roach                $folder = trim($folder, '/');
205d4265d07SGreg Roach                if ($folder !== '') {
206d4265d07SGreg Roach                    $folder .= '/';
207d4265d07SGreg Roach                }
208d4265d07SGreg Roach
209d4265d07SGreg Roach                // Generate a unique name for the file?
2109458f20aSGreg Roach                if ($auto === '1' || $tree->mediaFilesystem()->fileExists($folder . $file)) {
211d4265d07SGreg Roach                    $folder    = '';
212d4265d07SGreg Roach                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
213d4265d07SGreg Roach                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
214d4265d07SGreg Roach                }
215d4265d07SGreg Roach
216d4265d07SGreg Roach                try {
2179458f20aSGreg Roach                    $tree->mediaFilesystem()->writeStream($folder . $file, $uploaded_file->getStream()->detach());
218d4265d07SGreg Roach
219d4265d07SGreg Roach                    return $folder . $file;
22028d026adSGreg Roach                } catch (RuntimeException | InvalidArgumentException) {
221d4265d07SGreg Roach                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
222d4265d07SGreg Roach
223d4265d07SGreg Roach                    return '';
224d4265d07SGreg Roach                }
225d4265d07SGreg Roach        }
226748dbe15SGreg Roach
227748dbe15SGreg Roach        return '';
228d4265d07SGreg Roach    }
229d4265d07SGreg Roach
230d4265d07SGreg Roach    /**
231d4265d07SGreg Roach     * Convert the media file attributes into GEDCOM format.
232d4265d07SGreg Roach     *
233d4265d07SGreg Roach     * @param string $file
234d4265d07SGreg Roach     * @param string $type
235d4265d07SGreg Roach     * @param string $title
23645fc2659SGreg Roach     * @param string $note
237d4265d07SGreg Roach     *
238d4265d07SGreg Roach     * @return string
239d4265d07SGreg Roach     */
24045fc2659SGreg Roach    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
241d4265d07SGreg Roach    {
242d4265d07SGreg Roach        $gedcom = '1 FILE ' . $file;
24345fc2659SGreg Roach
2442bba966dSGreg Roach        if (str_contains($file, '://')) {
2452bba966dSGreg Roach            $format = '';
2462bba966dSGreg Roach        } else {
2470ea5fc1cSGreg Roach            $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
24803f0aef8SGreg Roach            $format = Registry::elementFactory()->make('OBJE:FILE:FORM')->canonical($format);
2492bba966dSGreg Roach        }
25045fc2659SGreg Roach
25103f0aef8SGreg Roach        if ($format !== '') {
25203f0aef8SGreg Roach            $gedcom .= "\n2 FORM " . strtr($format, ["\n" => "\n3 CONT "]);
25345fc2659SGreg Roach        } elseif ($type !== '') {
25445fc2659SGreg Roach            $gedcom .= "\n2 FORM";
255d4265d07SGreg Roach        }
25645fc2659SGreg Roach
25745fc2659SGreg Roach        if ($type !== '') {
25803f0aef8SGreg Roach            $gedcom .= "\n3 TYPE " . strtr($type, ["\n" => "\n4 CONT "]);
25945fc2659SGreg Roach        }
26045fc2659SGreg Roach
261d4265d07SGreg Roach        if ($title !== '') {
26203f0aef8SGreg Roach            $gedcom .= "\n2 TITL " . strtr($title, ["\n" => "\n3 CONT "]);
263d4265d07SGreg Roach        }
264d4265d07SGreg Roach
26545fc2659SGreg Roach        if ($note !== '') {
26645fc2659SGreg Roach            // Convert HTML line endings to GEDCOM continuations
26703f0aef8SGreg Roach            $gedcom .= "\n1 NOTE " . strtr($note, ["\n" => "\n2 CONT "]);
26845fc2659SGreg Roach        }
26945fc2659SGreg Roach
270d4265d07SGreg Roach        return $gedcom;
271d4265d07SGreg Roach    }
27213aa75d8SGreg Roach
27313aa75d8SGreg Roach    /**
27413aa75d8SGreg Roach     * Fetch a list of all files on disk (in folders used by any tree).
27513aa75d8SGreg Roach     *
276f7cf8a15SGreg Roach     * @param FilesystemOperator $filesystem $filesystem to search
277f7cf8a15SGreg Roach     * @param string             $folder     Root folder
27813aa75d8SGreg Roach     * @param bool               $subfolders Include subfolders
27913aa75d8SGreg Roach     *
28036779af1SGreg Roach     * @return Collection<int,string>
28113aa75d8SGreg Roach     */
282f7cf8a15SGreg Roach    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
28313aa75d8SGreg Roach    {
284f0448b68SGreg Roach        try {
285f9b64f46SGreg Roach            $files = $filesystem
286f9b64f46SGreg Roach                ->listContents($folder, $subfolders)
287f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
288f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
289f9b64f46SGreg Roach                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
290f0448b68SGreg Roach                ->toArray();
29128d026adSGreg Roach        } catch (FilesystemException) {
292f0448b68SGreg Roach            $files = [];
293f0448b68SGreg Roach        }
294f7cf8a15SGreg Roach
295f0448b68SGreg Roach        return new Collection($files);
29613aa75d8SGreg Roach    }
29713aa75d8SGreg Roach
29813aa75d8SGreg Roach    /**
29913aa75d8SGreg Roach     * Fetch a list of all files on in the database.
30013aa75d8SGreg Roach     *
30113aa75d8SGreg Roach     * @param string $media_folder Root folder
30213aa75d8SGreg Roach     * @param bool   $subfolders   Include subfolders
30313aa75d8SGreg Roach     *
30436779af1SGreg Roach     * @return Collection<int,string>
30513aa75d8SGreg Roach     */
30613aa75d8SGreg Roach    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
30713aa75d8SGreg Roach    {
30813aa75d8SGreg Roach        $query = DB::table('media_file')
30913aa75d8SGreg Roach            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
31013aa75d8SGreg Roach            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
31113aa75d8SGreg Roach            //->where('multimedia_file_refn', 'LIKE', '%/%')
31213aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
31313aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
314059898c9SGreg Roach            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
31513aa75d8SGreg Roach
31613aa75d8SGreg Roach        if (!$subfolders) {
31713aa75d8SGreg Roach            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
31813aa75d8SGreg Roach        }
31913aa75d8SGreg Roach
320059898c9SGreg Roach        return $query
321059898c9SGreg Roach            ->orderBy(new Expression('setting_value || multimedia_file_refn'))
322059898c9SGreg Roach            ->pluck(new Expression('setting_value || multimedia_file_refn AS path'));
32313aa75d8SGreg Roach    }
32413aa75d8SGreg Roach
32513aa75d8SGreg Roach    /**
326f9b64f46SGreg Roach     * Generate a list of all folders used by a tree.
327f9b64f46SGreg Roach     *
328f9b64f46SGreg Roach     * @param Tree $tree
329f9b64f46SGreg Roach     *
33036779af1SGreg Roach     * @return Collection<int,string>
331f9b64f46SGreg Roach     * @throws FilesystemException
332f9b64f46SGreg Roach     */
333f9b64f46SGreg Roach    public function mediaFolders(Tree $tree): Collection
334f9b64f46SGreg Roach    {
3359458f20aSGreg Roach        $folders = $tree->mediaFilesystem()
336782714c2SGreg Roach            ->listContents('', FilesystemReader::LIST_DEEP)
337f9b64f46SGreg Roach            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
338f9b64f46SGreg Roach            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
339f9b64f46SGreg Roach            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
340f9b64f46SGreg Roach            ->toArray();
341f9b64f46SGreg Roach
342f9b64f46SGreg Roach        return new Collection($folders);
343f9b64f46SGreg Roach    }
344f9b64f46SGreg Roach
345f9b64f46SGreg Roach    /**
34613aa75d8SGreg Roach     * Generate a list of all folders in either the database or the filesystem.
34713aa75d8SGreg Roach     *
348f7cf8a15SGreg Roach     * @param FilesystemOperator $data_filesystem
34913aa75d8SGreg Roach     *
35036779af1SGreg Roach     * @return Collection<array-key,string>
351f7cf8a15SGreg Roach     * @throws FilesystemException
35213aa75d8SGreg Roach     */
353f7cf8a15SGreg Roach    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
35413aa75d8SGreg Roach    {
35513aa75d8SGreg Roach        $db_folders = DB::table('media_file')
356c6bad570SGreg Roach            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
357c6bad570SGreg Roach                $join
358c6bad570SGreg Roach                    ->on('gedcom_id', '=', 'm_file')
359c6bad570SGreg Roach                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
360c6bad570SGreg Roach            })
36113aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
36213aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
363059898c9SGreg Roach            ->pluck(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path"))
364*f25fc0f9SGreg Roach            ->map(static fn (string $path): string => dirname($path) . '/');
36513aa75d8SGreg Roach
366c6bad570SGreg Roach        $media_roots = DB::table('gedcom')
367c6bad570SGreg Roach            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
368c6bad570SGreg Roach                $join
369c6bad570SGreg Roach                    ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id')
370c6bad570SGreg Roach                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
371c6bad570SGreg Roach            })
372c6bad570SGreg Roach            ->where('gedcom.gedcom_id', '>', '0')
3732a0263cdSGreg Roach            ->pluck(new Expression("COALESCE(setting_value, 'media/') AS path"))
3748c627a69SGreg Roach            ->uniqueStrict();
37513aa75d8SGreg Roach
37613aa75d8SGreg Roach        $disk_folders = new Collection($media_roots);
37713aa75d8SGreg Roach
37813aa75d8SGreg Roach        foreach ($media_roots as $media_folder) {
379f9b64f46SGreg Roach            $tmp = $data_filesystem
380782714c2SGreg Roach                ->listContents($media_folder, FilesystemReader::LIST_DEEP)
381f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
382f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
383f9b64f46SGreg Roach                ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/')
384f7cf8a15SGreg Roach                ->toArray();
38513aa75d8SGreg Roach
38613aa75d8SGreg Roach            $disk_folders = $disk_folders->concat($tmp);
38713aa75d8SGreg Roach        }
38813aa75d8SGreg Roach
38913aa75d8SGreg Roach        return $disk_folders->concat($db_folders)
3908c627a69SGreg Roach            ->uniqueStrict()
3916edebcedSGreg Roach            ->sort(I18N::comparator())
392*f25fc0f9SGreg Roach            ->mapWithKeys(static fn (string $folder): array => [$folder => $folder]);
39313aa75d8SGreg Roach    }
394f7cf8a15SGreg Roach
395f7cf8a15SGreg Roach    /**
396f9b64f46SGreg Roach     * Ignore special media folders.
39708b5db2aSGreg Roach     *
39808b5db2aSGreg Roach     * @param string $path
39908b5db2aSGreg Roach     *
40008b5db2aSGreg Roach     * @return bool
401f7cf8a15SGreg Roach     */
402c6bad570SGreg Roach    private function ignorePath(string $path): bool
403f7cf8a15SGreg Roach    {
404d8809d62SGreg Roach        return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== [];
405f7cf8a15SGreg Roach    }
406d4265d07SGreg Roach}
407