1<?php
2/*
3 * vim:set softtabstop=4 shiftwidth=4 expandtab:
4 *
5 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
6 * Copyright 2001 - 2020 Ampache.org
7 *
8 * This program is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU Affero General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 * GNU Affero General Public License for more details.
17 *
18 * You should have received a copy of the GNU Affero General Public License
19 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
20 *
21 */
22
23namespace Ampache\Module\Catalog;
24
25use Ampache\Config\AmpConfig;
26use Ampache\Module\Playback\Stream;
27use Ampache\Module\Util\UtilityFactoryInterface;
28use Ampache\Repository\Model\Album;
29use Ampache\Repository\Model\Art;
30use Ampache\Repository\Model\Artist;
31use Ampache\Repository\Model\Catalog;
32use Ampache\Repository\Model\Media;
33use Ampache\Repository\Model\Metadata\Repository\Metadata;
34use Ampache\Repository\Model\Metadata\Repository\MetadataField;
35use Ampache\Repository\Model\Podcast_Episode;
36use Ampache\Repository\Model\Rating;
37use Ampache\Repository\Model\Song;
38use Ampache\Repository\Model\Song_Preview;
39use Ampache\Repository\Model\Video;
40use Ampache\Module\System\AmpError;
41use Ampache\Module\System\Core;
42use Ampache\Module\System\Dba;
43use Ampache\Module\Util\ObjectTypeToClassNameMapper;
44use Ampache\Module\Util\Recommendation;
45use Ampache\Module\Util\Ui;
46use Ampache\Module\Util\VaInfo;
47use Exception;
48
49/**
50 * This class handles all actual work in regards to local catalogs.
51 */
52class Catalog_local extends Catalog
53{
54    private $version     = '000001';
55    private $type        = 'local';
56    private $description = 'Local Catalog';
57
58    private $count;
59    private $songs_to_gather;
60    private $videos_to_gather;
61
62    /**
63     * get_description
64     * This returns the description of this catalog
65     */
66    public function get_description()
67    {
68        return $this->description;
69    } // get_description
70
71    /**
72     * get_version
73     * This returns the current version
74     */
75    public function get_version()
76    {
77        return $this->version;
78    } // get_version
79
80    /**
81     * get_type
82     * This returns the current catalog type
83     */
84    public function get_type()
85    {
86        return $this->type;
87    } // get_type
88
89    /**
90     * get_create_help
91     * This returns hints on catalog creation
92     */
93    public function get_create_help()
94    {
95        return "";
96    } // get_create_help
97
98    /**
99     * is_installed
100     * This returns true or false if local catalog is installed
101     */
102    public function is_installed()
103    {
104        $sql        = "SHOW TABLES LIKE 'catalog_local'";
105        $db_results = Dba::query($sql);
106
107        return (Dba::num_rows($db_results) > 0);
108    } // is_installed
109
110    /**
111     * install
112     * This function installs the local catalog
113     */
114    public function install()
115    {
116        $collation = (AmpConfig::get('database_collation', 'utf8mb4_unicode_ci'));
117        $charset   = (AmpConfig::get('database_charset', 'utf8mb4'));
118        $engine    = ($charset == 'utf8mb4') ? 'InnoDB' : 'MYISAM';
119
120        $sql = "CREATE TABLE `catalog_local` (`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `path` VARCHAR(255) COLLATE $collation NOT NULL, `catalog_id` INT(11) NOT NULL) ENGINE = $engine DEFAULT CHARSET=$charset COLLATE=$collation";
121        Dba::query($sql);
122
123        return true;
124    } // install
125
126    /**
127     * @return array
128     */
129    public function catalog_fields()
130    {
131        $fields = array();
132
133        $fields['path'] = array('description' => T_('Path'), 'type' => 'text');
134
135        return $fields;
136    }
137
138    public $path;
139
140    /**
141     * Constructor
142     *
143     * Catalog class constructor, pulls catalog information
144     * @param integer $catalog_id
145     */
146    public function __construct($catalog_id = null)
147    {
148        if ($catalog_id) {
149            $this->id = (int)($catalog_id);
150            $info     = $this->get_info($catalog_id);
151
152            foreach ($info as $key => $value) {
153                $this->$key = $value;
154            }
155        }
156    }
157
158    /**
159     * get_from_path
160     *
161     * Try to figure out which catalog path most closely resembles this one.
162     * This is useful when creating a new catalog to make sure we're not
163     * doubling up here.
164     * @param $path
165     * @return boolean|mixed
166     */
167    public static function get_from_path($path)
168    {
169        // First pull a list of all of the paths for the different catalogs
170        $sql        = "SELECT `catalog_id`, `path` FROM `catalog_local`";
171        $db_results = Dba::read($sql);
172
173        $catalog_paths  = array();
174        $component_path = $path;
175
176        while ($row = Dba::fetch_assoc($db_results)) {
177            $catalog_paths[$row['path']] = $row['catalog_id'];
178        }
179
180        // Break it down into its component parts and start looking for a catalog
181        do {
182            if ($catalog_paths[$component_path]) {
183                return $catalog_paths[$component_path];
184            }
185
186            // Keep going until the path stops changing
187            $old_path       = $component_path;
188            $component_path = realpath($component_path . '/../');
189        } while (strcmp($component_path, $old_path) != 0);
190
191        return false;
192    }
193
194    /**
195     * create_type
196     *
197     * This creates a new catalog type entry for a catalog
198     * It checks to make sure its parameters is not already used before creating
199     * the catalog.
200     * @param $catalog_id
201     * @param array $data
202     * @return boolean
203     */
204    public static function create_type($catalog_id, $data)
205    {
206        // Clean up the path just in case
207        $path = rtrim(rtrim(trim($data['path']), '/'), '\\');
208
209        if (!self::check_path($path)) {
210            AmpError::add('general', T_('Path was not specified'));
211
212            return false;
213        }
214
215        // Make sure this path isn't already in use by an existing catalog
216        $sql        = 'SELECT `id` FROM `catalog_local` WHERE `path` = ?';
217        $db_results = Dba::read($sql, array($path));
218
219        if (Dba::num_rows($db_results)) {
220            debug_event('local.catalog', 'Cannot add catalog with duplicate path ' . $path, 1);
221            /* HINT: directory (file path) */
222            AmpError::add('general', sprintf(T_('This path belongs to an existing local Catalog: %s'), $path));
223
224            return false;
225        }
226
227        $sql = 'INSERT INTO `catalog_local` (`path`, `catalog_id`) VALUES (?, ?)';
228        Dba::write($sql, array($path, $catalog_id));
229
230        return true;
231    }
232
233    /**
234     * add_files
235     *
236     * Recurses through $this->path and pulls out all mp3s and returns the
237     * full path in an array. Passes gather_type to determine if we need to
238     * check id3 information against the db.
239     * @param string $path
240     * @param array $options
241     * @param integer $counter
242     * @return boolean
243     */
244    public function add_files($path, $options, $counter = 0)
245    {
246        // See if we want a non-root path for the add
247        if (isset($options['subdirectory'])) {
248            $path = $options['subdirectory'];
249            unset($options['subdirectory']);
250
251            // Make sure the path doesn't end in a / or \
252            $path = rtrim($path, '/');
253            $path = rtrim($path, '\\');
254        }
255
256        // Correctly detect the slash we need to use here
257        if (strpos($path, '/') !== false) {
258            $slash_type = '/';
259        } else {
260            $slash_type = '\\';
261        }
262
263        /* Open up the directory */
264        $handle = opendir($path);
265
266        if (!is_resource($handle)) {
267            debug_event('local.catalog', "Unable to open $path", 3);
268            /* HINT: directory (file path) */
269            AmpError::add('catalog_add', sprintf(T_('Unable to open: %s'), $path));
270
271            return false;
272        }
273
274        /* Change the dir so is_dir works correctly */
275        if (!chdir($path)) {
276            debug_event('local.catalog', "Unable to chdir to $path", 2);
277            /* HINT: directory (file path) */
278            AmpError::add('catalog_add', sprintf(T_('Unable to change to directory: %s'), $path));
279
280            return false;
281        }
282
283        /* Recurse through this dir and create the files array */
284        while (false !== ($file = readdir($handle))) {
285            /* Skip to next if we've got . or .. */
286            if (substr($file, 0, 1) == '.') {
287                continue;
288            }
289            // reduce the crazy log info
290            if ($counter % 1000 == 0) {
291                debug_event('local.catalog', "Reading $file inside $path", 5);
292                debug_event('local.catalog', "Memory usage: " . (string) UI::format_bytes(memory_get_usage(true)), 5);
293            }
294            $counter++;
295
296            /* Create the new path */
297            $full_file = $path . $slash_type . $file;
298            $this->add_file($full_file, $options, $counter);
299        } // end while reading directory
300
301        if ($counter % 1000 == 0) {
302            debug_event('local.catalog', "Finished reading $path, closing handle", 5);
303        }
304
305        // This should only happen on the last run
306        if ($path == $this->path) {
307            Ui::update_text('add_count_' . $this->id, $this->count);
308        }
309
310        /* Close the dir handle */
311        @closedir($handle);
312
313        return true;
314    } // add_files
315
316    /**
317     * add_file
318     *
319     * @param $full_file
320     * @param array $options
321     * @param integer $counter
322     * @return boolean
323     * @throws Exception
324     */
325    public function add_file($full_file, $options, $counter = 0)
326    {
327        // Ensure that we've got our cache
328        $this->_create_filecache();
329
330        /* First thing first, check if file is already in catalog.
331         * This check is very quick, so it should be performed before any other checks to save time
332         */
333        if (isset($this->_filecache[strtolower($full_file)])) {
334            return false;
335        }
336
337        if (AmpConfig::get('no_symlinks')) {
338            if (is_link($full_file)) {
339                debug_event('local.catalog', "Skipping symbolic link $full_file", 5);
340
341                return false;
342            }
343        }
344
345        /* If it's a dir run this function again! */
346        if (is_dir($full_file)) {
347            $this->add_files($full_file, $options, $counter);
348
349            /* Change the dir so is_dir works correctly */
350            if (!chdir($full_file)) {
351                debug_event('local.catalog', "Unable to chdir to $full_file", 2);
352                /* HINT: directory (file path) */
353                AmpError::add('catalog_add', sprintf(T_('Unable to change to directory: %s'), $full_file));
354            }
355
356            /* Skip to the next file */
357            return true;
358        } // it's a directory
359
360        $is_audio_file = Catalog::is_audio_file($full_file);
361        $is_video_file = false;
362        if (AmpConfig::get('catalog_video_pattern')) {
363            $is_video_file = Catalog::is_video_file($full_file);
364        }
365        $is_playlist = false;
366        if ($options['parse_playlist'] && AmpConfig::get('catalog_playlist_pattern')) {
367            $is_playlist = Catalog::is_playlist_file($full_file);
368        }
369
370        /* see if this is a valid audio file or playlist file */
371        if ($is_audio_file || $is_video_file || $is_playlist) {
372            /* Now that we're sure its a file get filesize  */
373            $file_size = Core::get_filesize($full_file);
374
375            if (!$file_size) {
376                debug_event('local.catalog', "Unable to get filesize for $full_file", 2);
377                /* HINT: FullFile */
378                AmpError::add('catalog_add', sprintf(T_('Unable to get the filesize for "%s"'), $full_file));
379            } // file_size check
380
381            if (!Core::is_readable($full_file)) {
382                // not readable, warn user
383                debug_event('local.catalog', "$full_file is not readable by Ampache", 2);
384                /* HINT: filename (file path) */
385                AmpError::add('catalog_add', sprintf(T_("The file couldn't be read. Does it exist? %s"), $full_file));
386
387                return false;
388            }
389
390            // Check to make sure the filename is of the expected charset
391            if (function_exists('iconv')) {
392                $convok       = false;
393                $site_charset = AmpConfig::get('site_charset');
394                $lc_charset   = $site_charset;
395                if (AmpConfig::get('lc_charset')) {
396                    $lc_charset = AmpConfig::get('lc_charset');
397                }
398
399                $enc_full_file = iconv($lc_charset, $site_charset, $full_file);
400                if ($lc_charset != $site_charset) {
401                    $convok = (strcmp($full_file, iconv($site_charset, $lc_charset, $enc_full_file)) == 0);
402                } else {
403                    $convok = (strcmp($enc_full_file, $full_file) == 0);
404                }
405                if (!$convok) {
406                    debug_event('local.catalog',
407                        $full_file . ' has non-' . $site_charset . ' characters and can not be indexed, converted filename:' . $enc_full_file,
408                        1);
409                    /* HINT: FullFile */
410                    AmpError::add('catalog_add', sprintf(T_('"%s" does not match site charset'), $full_file));
411
412                    return false;
413                }
414                $full_file = $enc_full_file;
415
416                // Check again with good encoding
417                if (isset($this->_filecache[strtolower($full_file)])) {
418                    return false;
419                }
420            } // end if iconv
421
422            if ($is_playlist) {
423                // if it's a playlist
424                debug_event('local.catalog', 'Found playlist file to import: ' . $full_file, 5);
425                $this->_playlists[] = $full_file;
426            } else {
427                if (count($this->get_gather_types('music')) > 0) {
428                    if ($is_audio_file) {
429                        debug_event('local.catalog', 'Found song file to import: ' . $full_file, 5);
430                        $this->insert_local_song($full_file, $options);
431                    } else {
432                        debug_event('local.catalog', $full_file . " ignored, bad media type for this music catalog.", 5);
433
434                        return false;
435                    }
436                } else {
437                    if (count($this->get_gather_types('video')) > 0) {
438                        if ($is_video_file) {
439                            debug_event('local.catalog', 'Found video file to import: ' . $full_file, 5);
440                            $this->insert_local_video($full_file, $options);
441                        } else {
442                            debug_event('local.catalog',
443                                $full_file . " ignored, bad media type for this video catalog.", 5);
444
445                            return false;
446                        }
447                    }
448                }
449
450                $this->count++;
451                $file = str_replace(array('(', ')', '\''), '', $full_file);
452                if (Ui::check_ticker()) {
453                    Ui::update_text('add_count_' . $this->id, $this->count);
454                    Ui::update_text('add_dir_' . $this->id, scrub_out($file));
455                } // update our current state
456            } // if it's not an m3u
457
458            return true;
459        } else {
460            // if it matches the pattern
461            if ($counter % 1000 == 0) {
462                debug_event('local.catalog', "$full_file ignored, non-audio file or 0 bytes", 5);
463            }
464
465            return false;
466        } // else not an audio file
467    }
468
469    /**
470     * add_to_catalog
471     * this function adds new files to an
472     * existing catalog
473     * @param array $options
474     */
475    public function add_to_catalog($options = null)
476    {
477        if ($options == null) {
478            $options = array(
479                'gather_art' => true,
480                'parse_playlist' => false
481            );
482        }
483
484        $this->count                  = 0;
485        $this->songs_to_gather        = array();
486        $this->videos_to_gather       = array();
487
488        if (!defined('SSE_OUTPUT')) {
489            require Ui::find_template('show_adds_catalog.inc.php');
490            flush();
491        }
492
493        /* Set the Start time */
494        $start_time = time();
495
496        // Make sure the path doesn't end in a / or \
497        $this->path = rtrim($this->path, '/');
498        $this->path = rtrim($this->path, '\\');
499
500        // Prevent the script from timing out and flush what we've got
501        set_time_limit(0);
502
503        // If podcast catalog, we don't want to analyze files for now
504        if ($this->gather_types == "podcast") {
505            $this->sync_podcasts();
506        } else {
507            /* Get the songs and then insert them into the db */
508            $this->add_files($this->path, $options);
509
510            if ($options['parse_playlist'] && count($this->_playlists)) {
511                // Foreach Playlists we found
512                foreach ($this->_playlists as $full_file) {
513                    debug_event('local.catalog', 'Processing playlist: ' . $full_file, 5);
514                    $result = self::import_playlist($full_file, -1, 'public');
515                    if ($result['success']) {
516                        $file = basename($full_file);
517                        echo "\n$full_file\n";
518                        if (!empty($result['results'])) {
519                            foreach ($result['results'] as $file) {
520                                if ($file['found']) {
521                                    echo scrub_out($file['track']) . ": " . T_('Success') . ":\t" . scrub_out($file['file']) . "\n";
522                                } else {
523                                    echo "-: " . T_('Failure') . ":\t" . scrub_out($file['file']) . "\n";
524                                }
525                                flush();
526                            } // foreach songs
527                            echo "\n";
528                        }
529                    } // end if import worked
530                } // end foreach playlist files
531            }
532
533            if ($options['gather_art']) {
534                $catalog_id = $this->id;
535                if (!defined('SSE_OUTPUT')) {
536                    require Ui::find_template('show_gather_art.inc.php');
537                    flush();
538                }
539                $this->gather_art($this->songs_to_gather, $this->videos_to_gather);
540            }
541        }
542
543        /* Update the Catalog last_update */
544        $this->update_last_add();
545
546        $current_time = time();
547
548        $time_diff = ($current_time - $start_time) ?: 0;
549        $rate      = number_format(($time_diff > 0) ? $this->count / $time_diff : 0, 2);
550        if ($rate < 1) {
551            $rate = T_('N/A');
552        }
553
554        if (!defined('SSE_OUTPUT')) {
555            Ui::show_box_top();
556            Ui::update_text(T_('Catalog Updated'),
557                sprintf(T_('Total Time: [%s] Total Media: [%s] Media Per Second: [%s]'), date('i:s', $time_diff),
558                    $this->count, $rate));
559            Ui::show_box_bottom();
560        }
561    } // add_to_catalog
562
563    /**
564     * verify_catalog_proc
565     * This function compares the DB's information with the ID3 tags
566     */
567    public function verify_catalog_proc()
568    {
569        debug_event('local.catalog', 'Verify starting on ' . $this->name, 5);
570        set_time_limit(0);
571
572        $stats         = self::get_stats($this->id);
573        $number        = $stats['items'];
574        $total_updated = 0;
575        $this->count   = 0;
576
577        /** @var Song|Video $media_type */
578        foreach (array(Video::class, Song::class) as $media_type) {
579            $total = $stats['items'];
580            if ($total == 0) {
581                continue;
582            }
583            $chunks = (int)floor($total / 10000);
584            foreach (range(0, $chunks) as $chunk) {
585                // Try to be nice about memory usage
586                if ($chunk > 0) {
587                    $media_type::clear_cache();
588                }
589                $total_updated += $this->_verify_chunk(ObjectTypeToClassNameMapper::reverseMap($media_type), $chunk, 10000);
590            }
591        }
592
593        debug_event('local.catalog', "Verify finished, $total_updated updated in " . $this->name, 5);
594        $this->update_last_update();
595
596        return array('total' => $number, 'updated' => $total_updated);
597    } // verify_catalog_proc
598
599    /**
600     * _verify_chunk
601     * This verifies a chunk of the catalog, done to save
602     * memory
603     * @param string $tableName
604     * @param integer $chunk
605     * @param integer $chunk_size
606     * @return integer
607     */
608    private function _verify_chunk($tableName, $chunk, $chunk_size)
609    {
610        debug_event('local.catalog', "catalog " . $this->id . " starting verify on chunk $chunk", 5);
611        $count   = $chunk * $chunk_size;
612        $changed = 0;
613
614        $sql = ($tableName == 'song')
615            ? "SELECT `song`.`id`, `song`.`file`, `song`.`update_time` FROM `song` WHERE `song`.`album` IN (SELECT `song`.`album` FROM `song` LEFT JOIN `catalog` ON `song`.`catalog` = `catalog`.`id` WHERE `song`.`catalog`='$this->id' AND (`song`.`update_time` < `catalog`.`last_update` OR `song`.`addition_time` > `catalog`.`last_update`)) ORDER BY `song`.`album`, `song`.`file` LIMIT $count, $chunk_size"
616            : "SELECT `$tableName`.`id`, `$tableName`.`file`, `$tableName`.`update_time` FROM `$tableName` LEFT JOIN `catalog` ON `$tableName`.`catalog` = `catalog`.`id` WHERE `$tableName`.`catalog`='$this->id' AND `$tableName`.`update_time` < `catalog`.`last_update` ORDER BY `$tableName`.`update_time` DESC, `$tableName`.`file` LIMIT $count, $chunk_size";
617        $db_results = Dba::read($sql);
618
619        $class_name = ObjectTypeToClassNameMapper::map($tableName);
620
621        if (AmpConfig::get('memory_cache')) {
622            $media_ids = array();
623            while ($row = Dba::fetch_assoc($db_results, false)) {
624                $media_ids[] = $row['id'];
625            }
626            $class_name::build_cache($media_ids);
627            $db_results = Dba::read($sql);
628        }
629        $verify_by_time = AmpConfig::get('catalog_verify_by_time');
630        while ($row = Dba::fetch_assoc($db_results)) {
631            $count++;
632            if (Ui::check_ticker()) {
633                $file = str_replace(array('(', ')', '\''), '', $row['file']);
634                Ui::update_text('verify_count_' . $this->id, $count);
635                Ui::update_text('verify_dir_' . $this->id, scrub_out($file));
636            }
637
638            if (!Core::is_readable(Core::conv_lc_file($row['file']))) {
639                /* HINT: filename (file path) */
640                AmpError::add('general', sprintf(T_("The file couldn't be read. Does it exist? %s"), $row['file']));
641                debug_event('local.catalog', $row['file'] . ' does not exist or is not readable', 5);
642                continue;
643            }
644            $file_time = filemtime($row['file']);
645            // check the modification time on the file to see if it's worth checking the tags.
646            if ($verify_by_time && ($this->last_update > $file_time || $row['update_time'] > $file_time)) {
647                continue;
648            }
649
650            $media = new $class_name($row['id']);
651            $info  = self::update_media_from_tags($media, $this->get_gather_types(), $this->sort_pattern, $this->rename_pattern);
652            if ($info['change']) {
653                $changed++;
654            }
655            unset($info);
656        }
657
658        Ui::update_text('verify_count_' . $this->id, $count);
659
660        return $changed;
661    } // _verify_chunk
662
663    /**
664     * clean catalog procedure
665     *
666     * Removes local songs that no longer exist.
667     */
668    public function clean_catalog_proc()
669    {
670        if (!Core::is_readable($this->path)) {
671            // First sanity check; no point in proceeding with an unreadable catalog root.
672            debug_event('local.catalog', 'Catalog path:' . $this->path . ' unreadable, clean failed', 1);
673            AmpError::add('general', T_('Catalog root unreadable, stopping clean'));
674            echo AmpError::display('general');
675
676            return 0;
677        }
678
679        $dead_total  = 0;
680        $stats       = self::get_stats($this->id);
681        $this->count = 0;
682        foreach (array('video', 'song') as $media_type) {
683            $total = $stats['items'];
684            if ($total == 0) {
685                continue;
686            }
687            $chunks = floor($total / 10000);
688            $dead   = array();
689            foreach (range(0, $chunks) as $chunk) {
690                $dead = array_merge($dead, $this->_clean_chunk($media_type, $chunk, 10000));
691            }
692
693            $dead_count = count($dead);
694            // Check for unmounted path
695            if (!file_exists($this->path)) {
696                if ($dead_count >= $total) {
697                    debug_event('local.catalog', 'All files would be removed. Doing nothing.', 1);
698                    AmpError::add('general', T_('All files would be removed. Doing nothing'));
699                    continue;
700                }
701            }
702            if ($dead_count) {
703                $dead_total += $dead_count;
704                $sql = "DELETE FROM `$media_type` WHERE `id` IN (" . implode(',', $dead) . ")";
705                Dba::write($sql);
706            }
707        }
708
709        Metadata::garbage_collection();
710        MetadataField::garbage_collection();
711
712        return (int)$dead_total;
713    }
714
715    /**
716     * _clean_chunk
717     * This is the clean function and is broken into chunks to try to save a little memory
718     * @param $media_type
719     * @param $chunk
720     * @param $chunk_size
721     * @return array
722     */
723    private function _clean_chunk($media_type, $chunk, $chunk_size)
724    {
725        debug_event('local.catalog', "catalog " . $this->id . " Starting clean on chunk $chunk", 5);
726        $dead  = array();
727        $count = $chunk * $chunk_size;
728
729        $tableName = ObjectTypeToClassNameMapper::reverseMap($media_type);
730
731        $sql        = "SELECT `id`, `file` FROM `$tableName` WHERE `catalog` = ? LIMIT $count, $chunk_size;";
732        $db_results = Dba::read($sql, array($this->id));
733
734        while ($results = Dba::fetch_assoc($db_results)) {
735            //debug_event('local.catalog', 'Cleaning check on ' . $results['file'] . '(' . $results['id'] . ')', 5);
736            $count++;
737            if (Ui::check_ticker()) {
738                $file = str_replace(array('(', ')', '\''), '', $results['file']);
739                Ui::update_text('clean_count_' . $this->id, $count);
740                Ui::update_text('clean_dir_' . $this->id, scrub_out($file));
741            }
742            $file_info = Core::get_filesize(Core::conv_lc_file($results['file']));
743            if ($file_info < 1) {
744                debug_event('local.catalog', '_clean_chunk: {' . $results['id'] . '} File not found or empty ' . $results['file'], 5);
745                /* HINT: filename (file path) */
746                AmpError::add('general', sprintf(T_('File was not found or is 0 Bytes: %s'), $results['file']));
747
748                // Store it in an array we'll delete it later...
749                $dead[] = $results['id'];
750            } else {
751                // if error
752                if (!Core::is_readable(Core::conv_lc_file($results['file']))) {
753                    debug_event('local.catalog', $results['file'] . ' is not readable, but does exist', 1);
754                }
755            }
756        }
757
758        return $dead;
759    } //_clean_chunk
760
761    /**
762     * clean_file
763     *
764     * Clean up a single file checking that it's missing or just unreadable.
765     *
766     * @param string $file
767     * @param string $media_type
768     */
769    public function clean_file($file, $media_type = 'song')
770    {
771        $file_info = Core::get_filesize(Core::conv_lc_file($file));
772        if ($file_info < 1) {
773            $object_id = Catalog::get_id_from_file($file, $media_type);
774            debug_event('local.catalog', 'clean_file: {' . $object_id . '} File not found or empty ' . $file, 5);
775            /* HINT: filename (file path) */
776            AmpError::add('general', sprintf(T_('File was not found or is 0 Bytes: %s'), $file));
777            $params    = array($object_id);
778            switch ($media_type) {
779                case 'song':
780                    $sql = "REPLACE INTO `deleted_song` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist` FROM `song` WHERE `id` = ?;";
781                    Dba::write($sql, $params);
782                    break;
783                case 'video':
784                    $sql = "REPLACE INTO `deleted_video` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip` FROM `video` WHERE `id` = ?;";
785                    Dba::write($sql, $params);
786                    break;
787                case 'podcast_episode':
788                    $sql = "REPLACE INTO `deleted_podcast_episode` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`, `podcast`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip`, `podcast` FROM `podcast_episode` WHERE `id` = ?;";
789                    Dba::write($sql, $params);
790                    break;
791            }
792            $sql = "DELETE FROM `$media_type` WHERE `id` = ?";
793            Dba::write($sql, $params);
794        } elseif (!Core::is_readable(Core::conv_lc_file($file))) {
795            debug_event('local.catalog', "clean_file: " . $file . ' is not readable, but does exist', 1);
796        }
797    } // clean_file
798
799    /**
800     * insert_local_song
801     *
802     * Insert a song that isn't already in the database.
803     * @param $file
804     * @param array $options
805     * @return boolean|int
806     * @throws Exception
807     * @throws Exception
808     */
809    private function insert_local_song($file, $options = array())
810    {
811        $vainfo = $this->getUtilityFactory()->createVaInfo(
812            $file,
813            $this->get_gather_types('music'),
814            '',
815            '',
816            $this->sort_pattern,
817            $this->rename_pattern
818        );
819        $vainfo->get_info();
820
821        $key = VaInfo::get_tag_type($vainfo->tags);
822
823        $results            = VaInfo::clean_tag_info($vainfo->tags, $key, $file);
824        $results['catalog'] = $this->id;
825
826        if (isset($options['user_upload'])) {
827            $results['user_upload'] = $options['user_upload'];
828        }
829
830        if (isset($options['license'])) {
831            $results['license'] = $options['license'];
832        }
833
834        if ((int)$options['artist_id'] > 0) {
835            $results['artist_id']      = $options['artist_id'];
836            $results['albumartist_id'] = $options['artist_id'];
837            $artist                    = new Artist($results['artist_id']);
838            if ($artist->id) {
839                $results['artist'] = $artist->name;
840            }
841        }
842
843        if ((int)$options['album_id'] > 0) {
844            $results['album_id'] = $options['album_id'];
845            $album               = new Album($results['album_id']);
846            if (isset($album->id)) {
847                $results['album'] = $album->name;
848            }
849        }
850
851        if (count($this->get_gather_types('music')) > 0) {
852            if (AmpConfig::get('catalog_check_duplicate')) {
853                if (Song::find($results)) {
854                    debug_event('local.catalog', 'skipping_duplicate ' . $file, 5);
855
856                    return false;
857                }
858            }
859
860            if ($options['move_match_pattern']) {
861                $patres = VaInfo::parse_pattern($file, $this->sort_pattern, $this->rename_pattern);
862                if ($patres['artist'] != $results['artist'] || $patres['album'] != $results['album'] || $patres['track'] != $results['track'] || $patres['title'] != $results['title']) {
863                    $pattern = $this->sort_pattern . DIRECTORY_SEPARATOR . $this->rename_pattern;
864                    // Remove first left directories from filename to match pattern
865                    $cntslash = substr_count($pattern, preg_quote(DIRECTORY_SEPARATOR)) + 1;
866                    $filepart = explode(DIRECTORY_SEPARATOR, $file);
867                    if (count($filepart) > $cntslash) {
868                        $mvfile  = implode(DIRECTORY_SEPARATOR, array_slice($filepart, 0, count($filepart) - $cntslash));
869                        preg_match_all('/\%\w/', $pattern, $elements);
870                        foreach ($elements[0] as $key => $value) {
871                            $key     = translate_pattern_code($value);
872                            $pattern = str_replace($value, $results[$key], $pattern);
873                        }
874                        $mvfile .= DIRECTORY_SEPARATOR . $pattern . '.' . pathinfo($file, PATHINFO_EXTENSION);
875                        debug_event('local.catalog',
876                            'Unmatching pattern, moving `' . $file . '` to `' . $mvfile . '`...', 5);
877
878                        $mvdir = pathinfo($mvfile, PATHINFO_DIRNAME);
879                        if (!is_dir($mvdir)) {
880                            mkdir($mvdir, 0777, true);
881                        }
882                        if (rename($file, $mvfile)) {
883                            $results['file'] = $mvfile;
884                        } else {
885                            debug_event('local.catalog', 'File rename failed', 3);
886                        }
887                    }
888                }
889            }
890        }
891
892        $song_id = Song::insert($results);
893        if ($song_id) {
894            // If song rating tag exists and is well formed (array user=>rating), add it
895            if (array_key_exists('rating', $results) && is_array($results['rating'])) {
896                // For each user's ratings, call the function
897                foreach ($results['rating'] as $user => $rating) {
898                    debug_event('local.catalog', "Setting rating for Song $song_id to $rating for user $user", 5);
899                    $o_rating = new Rating($song_id, 'song');
900                    $o_rating->set_rating($rating, $user);
901                }
902            }
903            // Extended metadata loading is not deferred, retrieve it now
904            if (!AmpConfig::get('deferred_ext_metadata')) {
905                $song = new Song($song_id);
906                Recommendation::get_artist_info($song->artist);
907            }
908            if (Song::isCustomMetadataEnabled()) {
909                $song    = new Song($song_id);
910                $results = array_diff_key($results, array_flip($song->getDisabledMetadataFields()));
911                self::add_metadata($song, $results);
912            }
913            $this->songs_to_gather[] = $song_id;
914
915            $this->_filecache[strtolower($file)] = $song_id;
916        }
917
918        return $song_id;
919    }
920
921    /**
922     * insert_local_video
923     * This inserts a video file into the video file table the tag
924     * information we can get is super sketchy so it's kind of a crap shoot
925     * here
926     * @param $file
927     * @param array $options
928     * @return integer
929     * @throws Exception
930     * @throws Exception
931     */
932    public function insert_local_video($file, $options = array())
933    {
934        /* Create the vainfo object and get info */
935        $gtypes = $this->get_gather_types('video');
936
937        $vainfo = $this->getUtilityFactory()->createVaInfo(
938            $file,
939            $gtypes,
940            '',
941            '',
942            $this->sort_pattern,
943            $this->rename_pattern
944        );
945        $vainfo->get_info();
946
947        $tag_name           = VaInfo::get_tag_type($vainfo->tags, 'metadata_order_video');
948        $results            = VaInfo::clean_tag_info($vainfo->tags, $tag_name, $file);
949        $results['catalog'] = $this->id;
950
951        $video_id = Video::insert($results, $gtypes, $options);
952        if ($results['art']) {
953            $art = new Art($video_id, 'video');
954            $art->insert_url($results['art']);
955
956            if (AmpConfig::get('generate_video_preview')) {
957                Video::generate_preview($video_id);
958            }
959        } else {
960            $this->videos_to_gather[] = $video_id;
961        }
962
963        $this->_filecache[strtolower($file)] = 'v_' . $video_id;
964
965        return $video_id;
966    } // insert_local_video
967
968    private function sync_podcasts()
969    {
970        $podcasts = self::get_podcasts();
971        foreach ($podcasts as $podcast) {
972            $podcast->sync_episodes(false);
973            $episodes = $podcast->get_episodes('pending');
974            foreach ($episodes as $episode_id) {
975                $episode = new Podcast_Episode($episode_id);
976                $episode->gather();
977                $this->count++;
978            }
979        }
980    }
981
982    /**
983     * check_local_mp3
984     * Checks the song to see if it's there already returns true if found, false if not
985     * @param string $full_file
986     * @param string $gather_type
987     * @return boolean
988     */
989    public function check_local_mp3($full_file, $gather_type = '')
990    {
991        $file_date = filemtime($full_file);
992        if ($file_date < $this->last_add) {
993            debug_event('local.catalog', 'Skipping ' . $full_file . ' File modify time before last add run', 3);
994
995            return true;
996        }
997
998        $sql        = "SELECT `id` FROM `song` WHERE `file` = ?";
999        $db_results = Dba::read($sql, array($full_file));
1000
1001        // If it's found then return true
1002        if (Dba::fetch_row($db_results)) {
1003            return true;
1004        }
1005
1006        return false;
1007    } // check_local_mp3
1008
1009    /**
1010     * @param string $file_path
1011     * @return string|string[]
1012     */
1013    public function get_rel_path($file_path)
1014    {
1015        $catalog_path = rtrim($this->path, "/");
1016
1017        return (str_replace($catalog_path . "/", "", $file_path));
1018    }
1019
1020    /**
1021     * format
1022     *
1023     * This makes the object human-readable.
1024     */
1025    public function format()
1026    {
1027        parent::format();
1028        $this->f_info      = $this->path;
1029        $this->f_full_info = $this->path;
1030    }
1031
1032    /**
1033     * @param Podcast_Episode|Song|Song_Preview|Video $media
1034     * @return Media|Podcast_Episode|Song|Song_Preview|Video|null
1035     */
1036    public function prepare_media($media)
1037    {
1038        // Do nothing, it's just file...
1039        return $media;
1040    }
1041
1042    /**
1043     * check_path
1044     * Checks the path to see if it's there or conflicting with an existing catalog
1045     * @param string $path
1046     * @return boolean
1047     */
1048    public static function check_path($path)
1049    {
1050        if (!strlen($path)) {
1051            AmpError::add('general', T_('Path was not specified'));
1052
1053            return false;
1054        }
1055
1056        // Make sure that there isn't a catalog with a directory above this one
1057        if (self::get_from_path($path)) {
1058            AmpError::add('general', T_('Specified path is inside an existing catalog'));
1059
1060            return false;
1061        }
1062
1063        // Make sure the path is readable/exists
1064        if (!Core::is_readable($path)) {
1065            debug_event('local.catalog', 'Cannot add catalog at unopenable path ' . $path, 1);
1066            /* HINT: directory (file path) */
1067            AmpError::add('general', sprintf(T_("The folder couldn't be read. Does it exist? %s"), scrub_out($path)));
1068
1069            return false;
1070        }
1071
1072        return true;
1073    } // check_path
1074
1075    /**
1076     * move_catalog_proc
1077     * This function updates the file path of the catalog to a new location
1078     * @param string $new_path
1079     * @return boolean
1080     */
1081    public function move_catalog_proc($new_path)
1082    {
1083        if (!self::check_path($new_path)) {
1084            return false;
1085        }
1086        if ($this->path == $new_path) {
1087            debug_event('local.catalog', 'The new path equals the old path: ' . $new_path, 5);
1088
1089            return false;
1090        }
1091        $sql    = "UPDATE `catalog_local` SET `path` = ? WHERE `catalog_id` = ?";
1092        $params = array($new_path, $this->id);
1093        Dba::write($sql, $params);
1094
1095        $sql    = "UPDATE `song` SET `file` = REPLACE(`file`, '" . Dba::escape($this->path) . "', '" . Dba::escape($new_path) . "') WHERE `catalog` = ?";
1096        $params = array($this->id);
1097        Dba::write($sql, $params);
1098
1099        return true;
1100    } // move_catalog_proc
1101
1102    /**
1103     * cache_catalog_proc
1104     * @return boolean
1105     */
1106    public function cache_catalog_proc()
1107    {
1108        $m4a    = AmpConfig::get('cache_m4a');
1109        $flac   = AmpConfig::get('cache_flac');
1110        $mpc    = AmpConfig::get('cache_mpc');
1111        $ogg    = AmpConfig::get('cache_ogg');
1112        $oga    = AmpConfig::get('cache_oga');
1113        $opus   = AmpConfig::get('cache_opus');
1114        $wav    = AmpConfig::get('cache_wav');
1115        $wma    = AmpConfig::get('cache_wma');
1116        $aif    = AmpConfig::get('cache_aif');
1117        $aiff   = AmpConfig::get('cache_aiff');
1118        $ape    = AmpConfig::get('cache_ape');
1119        $shn    = AmpConfig::get('cache_shn');
1120        $mp3    = AmpConfig::get('cache_mp3');
1121        $target = AmpConfig::get('cache_target');
1122        $path   = (string)AmpConfig::get('cache_path', '');
1123        // need a destination and target filetype
1124        if ((!is_dir($path) || !$target)) {
1125            debug_event('local.catalog', 'Check your cache_path and cache_target settings', 5);
1126
1127            return false;
1128        }
1129        // need at least one type to transcode
1130        if ($m4a && !$flac && !$mpc && !$ogg && !$oga && !$opus && !$wav && !$wma && !$aif && !$aiff && !$ape && !$shn && !$mp3) {
1131            debug_event('local.catalog', 'You need to pick at least 1 file format to cache', 5);
1132
1133            return false;
1134        }
1135        // make a folder per catalog
1136        if (!is_dir(rtrim(trim($path), '/') . '/' . $this->id)) {
1137            mkdir(rtrim(trim($path), '/') . '/' . $this->id, 0777, true);
1138        }
1139        $sql    = "SELECT `id` FROM `song` WHERE `catalog` = ? ";
1140        $params = array($this->id);
1141        $join   = 'AND (';
1142        if ($m4a) {
1143            $sql .= "$join `file` LIKE '%.m4a' ";
1144            $join = 'OR';
1145        }
1146        if ($flac) {
1147            $sql .= "$join `file` LIKE '%.flac' ";
1148            $join = 'OR';
1149        }
1150        if ($mpc) {
1151            $sql .= "$join `file` LIKE '%.mpc' ";
1152            $join = 'OR';
1153        }
1154        if ($ogg) {
1155            $sql .= "$join `file` LIKE '%.ogg' ";
1156            $join = 'OR';
1157        }
1158        if ($oga) {
1159            $sql .= "$join `file` LIKE '%.oga' ";
1160            $join = 'OR';
1161        }
1162        if ($opus) {
1163            $sql .= "$join `file` LIKE '%.opus' ";
1164            $join = 'OR';
1165        }
1166        if ($wav) {
1167            $sql .= "$join `file` LIKE '%.wav' ";
1168            $join = 'OR';
1169        }
1170        if ($wma) {
1171            $sql .= "$join `file` LIKE '%.wma' ";
1172            $join = 'OR';
1173        }
1174        if ($aif) {
1175            $sql .= "$join `file` LIKE '%.aif' ";
1176            $join = 'OR';
1177        }
1178        if ($aiff) {
1179            $sql .= "$join `file` LIKE '%.aiff' ";
1180            $join = 'OR';
1181        }
1182        if ($ape) {
1183            $sql .= "$join `file` LIKE '%.ape' ";
1184            $join = 'OR';
1185        }
1186        if ($shn) {
1187            $sql .= "$join `file` LIKE '%.shn' ";
1188        }
1189        if ($mp3) {
1190            $sql .= "$join `file` LIKE '%.mp3' ";
1191        }
1192        if ($sql == "SELECT `id` FROM `song` WHERE `catalog` = ? ") {
1193            return false;
1194        }
1195        $sql .= ');';
1196        $results    = array();
1197        $db_results = Dba::read($sql, $params);
1198
1199        while ($row = Dba::fetch_assoc($db_results)) {
1200            $results[] = (int)$row['id'];
1201        }
1202        foreach ($results as $song_id) {
1203            $song        = new Song($song_id);
1204            $target_file = rtrim(trim($path), '/') . '/' . $this->id . '/' . $song_id . '.' . $target;
1205            $file_exists = is_file($target_file);
1206            if ($file_exists) {
1207                // get the time for the cached file and compare
1208                $vainfo = $this->getUtilityFactory()->createVaInfo(
1209                    $target_file,
1210                    $this->get_gather_types('music'),
1211                    '',
1212                    '',
1213                    $this->sort_pattern,
1214                    $this->rename_pattern
1215                );
1216                if ($song->time > 0 && !$vainfo->check_time($song->time)) {
1217                    debug_event('local.catalog', 'check_time FAILED for: ' . $song->file, 5);
1218                }
1219            }
1220            if (!$file_exists) {
1221                Stream::start_transcode($song, $target, 'cache_catalog_proc', array($target_file));
1222                debug_event('local.catalog', 'Saved: ' . $song_id . ' to: {' . $target_file . '}', 5);
1223            }
1224        }
1225
1226        return true;
1227    }
1228
1229    /**
1230     * @deprecated Inject by constructor
1231     */
1232    private function getUtilityFactory(): UtilityFactoryInterface
1233    {
1234        global $dic;
1235
1236        return $dic->get(UtilityFactoryInterface::class);
1237    }
1238}
1239