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