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 23declare(strict_types=0); 24 25namespace Ampache\Module\Catalog; 26 27use Ampache\Config\AmpConfig; 28use Ampache\Module\Util\UtilityFactoryInterface; 29use Ampache\Repository\Model\Catalog; 30use Ampache\Repository\Model\Media; 31use Ampache\Repository\Model\Podcast_Episode; 32use Ampache\Repository\Model\Song; 33use Ampache\Repository\Model\Song_Preview; 34use Ampache\Repository\Model\Video; 35use Ampache\Module\System\AmpError; 36use Ampache\Module\System\Core; 37use Ampache\Module\System\Dba; 38use Ampache\Module\Util\Ui; 39use Ampache\Module\Util\VaInfo; 40use Exception; 41use ReflectionException; 42 43/** 44 * This class handles all actual work in regards to remote Seafile catalogs. 45 */ 46class Catalog_Seafile extends Catalog 47{ 48 private static $version = '000001'; 49 private static $type = 'seafile'; 50 private static $description = 'Seafile Remote Catalog'; 51 private static $table_name = 'catalog_seafile'; 52 53 private $seafile; 54 55 /** 56 * get_description 57 * This returns the description of this catalog 58 */ 59 public function get_description() 60 { 61 return self::$description; 62 } // get_description 63 64 /** 65 * get_version 66 * This returns the current version 67 */ 68 public function get_version() 69 { 70 return self::$version; 71 } // get_version 72 73 /** 74 * get_type 75 * This returns the current catalog type 76 */ 77 public function get_type() 78 { 79 return self::$type; 80 } // get_type 81 82 /** 83 * get_create_help 84 * This returns hints on catalog creation 85 */ 86 public function get_create_help() 87 { 88 $help = "<ul><li>" . T_("Install a Seafile server as described in the documentation") . "</li><li>" . T_("Enter URL to server (e.g. 'https://seafile.example.com') and library name (e.g. 'Music').") . "</li><li>" . T_("API Call Delay is the delay inserted between repeated requests to Seafile (such as during an Add or Clean action) to accommodate Seafile's Rate Limiting.") . "<br/>" . T_("The default is tuned towards Seafile's default rate limit settings.") . "</li><li>" . T_("After creating the Catalog, you must 'Make it ready' on the Catalog table.") . "</li></ul>"; 89 90 return sprintf($help, "<a target='_blank' href='https://www.seafile.com/'>https://www.seafile.com/</a>", 91 "<a href='https://forum.syncwerk.com/t/too-many-requests-when-using-web-api-status-code-429/2330'>", 92 "</a>"); 93 } // get_create_help 94 95 /** 96 * is_installed 97 * This returns true or false if remote catalog is installed 98 */ 99 public function is_installed() 100 { 101 $sql = "SHOW TABLES LIKE '" . self::$table_name . "'"; 102 $db_results = Dba::query($sql); 103 104 return (Dba::num_rows($db_results) > 0); 105 } // is_installed 106 107 /** 108 * install 109 * This function installs the remote catalog 110 */ 111 public function install() 112 { 113 $collation = (AmpConfig::get('database_collation', 'utf8mb4_unicode_ci')); 114 $charset = (AmpConfig::get('database_charset', 'utf8mb4')); 115 $engine = ($charset == 'utf8mb4') ? 'InnoDB' : 'MYISAM'; 116 117 $sql = "CREATE TABLE `" . self::$table_name . "` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `server_uri` VARCHAR(255) COLLATE $collation NOT NULL, `api_key` VARCHAR(100) COLLATE $collation NOT NULL, `library_name` VARCHAR(255) COLLATE $collation NOT NULL, `api_call_delay` INT NOT NULL, `catalog_id` INT(11) NOT NULL) ENGINE = $engine DEFAULT CHARSET=$charset COLLATE=$collation"; 118 Dba::query($sql); 119 120 return true; 121 } 122 123 /** 124 * catalog_fields 125 * 126 * Return the necessary settings fields for creating a new Seafile catalog 127 * @return array 128 */ 129 public function catalog_fields() 130 { 131 $fields = array(); 132 133 $fields['server_uri'] = array( 134 'description' => T_('Server URI'), 135 'type' => 'text', 136 'value' => 'https://seafile.example.org/' 137 ); 138 $fields['library_name'] = array('description' => T_('Library Name'), 'type' => 'text', 'value' => 'Music'); 139 $fields['api_call_delay'] = array('description' => T_('API Call Delay'), 'type' => 'number', 'value' => '250'); 140 $fields['username'] = array('description' => T_('Seafile Username/Email'), 'type' => 'text', 'value' => ''); 141 $fields['password'] = array('description' => T_('Seafile Password'), 'type' => 'password', 'value' => ''); 142 143 return $fields; 144 } 145 146 /** 147 * isReady 148 * 149 * Returns whether the catalog is ready for use. 150 */ 151 public function isReady() 152 { 153 return $this->seafile->ready(); 154 } 155 156 /** 157 * create_type 158 * 159 * This creates a new catalog type entry for a catalog 160 * @param $catalog_id 161 * @param array $data 162 * @return boolean 163 */ 164 public static function create_type($catalog_id, $data) 165 { 166 $server_uri = rtrim(trim($data['server_uri']), '/'); 167 $library_name = trim($data['library_name']); 168 $api_call_delay = trim($data['api_call_delay']); 169 $username = trim($data['username']); 170 $password = trim($data['password']); 171 172 if (!strlen($server_uri)) { 173 AmpError::add('general', T_('Seafile server URL is required')); 174 175 return false; 176 } 177 178 if (!strlen($library_name)) { 179 AmpError::add('general', T_('Seafile server library name is required')); 180 181 return false; 182 } 183 184 if (!strlen($username)) { 185 AmpError::add('general', T_('Seafile username is required')); 186 187 return false; 188 } 189 190 if (!strlen($password)) { 191 AmpError::add('general', T_('Seafile password is required')); 192 193 return false; 194 } 195 196 if (!is_numeric($api_call_delay)) { 197 AmpError::add('general', T_('API Call Delay must have a numeric value')); 198 199 return false; 200 } 201 202 try { 203 $api_key = SeafileAdapter::request_api_key($server_uri, $username, $password); 204 205 debug_event('seafile_catalog', 'Retrieved API token for user ' . $username . '.', 1); 206 } catch (Exception $error) { 207 /* HINT: exception error message */ 208 AmpError::add('general', 209 sprintf(T_('There was a problem authenticating against the Seafile API: %s'), $error->getMessage())); 210 debug_event('seafile_catalog', 'Exception while Authenticating: ' . $error->getMessage(), 2); 211 } 212 213 if ($api_key == null) { 214 return false; 215 } 216 217 $sql = "INSERT INTO `catalog_seafile` (`server_uri`, `api_key`, `library_name`, `api_call_delay`, `catalog_id`) VALUES (?, ?, ?, ?, ?)"; 218 Dba::write($sql, array($server_uri, $api_key, $library_name, (int)($api_call_delay), $catalog_id)); 219 220 return true; 221 } 222 223 /** 224 * Constructor 225 * 226 * Catalog class constructor, pulls catalog information 227 * @param integer $catalog_id 228 */ 229 public function __construct($catalog_id = null) 230 { 231 if ($catalog_id) { 232 $this->id = (int)$catalog_id; 233 $info = $this->get_info($catalog_id); 234 235 $this->seafile = new SeafileAdapter($info['server_uri'], $info['library_name'], $info['api_call_delay'], 236 $info['api_key']); 237 } 238 } 239 240 /** 241 * @param string $file_path 242 * @return string 243 */ 244 public function get_rel_path($file_path) 245 { 246 $arr = $this->seafile->from_virtual_path($file_path); 247 248 return $arr['path'] . "/" . $arr['filename']; 249 } 250 251 /** 252 * add_to_catalog 253 * this function adds new files to an 254 * existing catalog 255 * @param array $options 256 * @return boolean 257 */ 258 public function add_to_catalog($options = null) 259 { 260 // Prevent the script from timing out 261 set_time_limit(0); 262 263 if (!defined('SSE_OUTPUT')) { 264 Ui::show_box_top(T_('Running Seafile Remote Update')); 265 } 266 267 $success = false; 268 269 if ($this->seafile->prepare()) { 270 $count = $this->seafile->for_all_files(function ($file) { 271 if ($file->size == 0) { 272 debug_event('seafile_catalog', 'read ' . $file->name . " ignored, 0 bytes", 5); 273 274 return 0; 275 } 276 277 $is_audio_file = Catalog::is_audio_file($file->name); 278 $is_video_file = Catalog::is_video_file($file->name); 279 280 if ($is_audio_file && count($this->get_gather_types('music')) > 0) { 281 if ($this->insert_song($file)) { 282 return 1; 283 } 284 //} elseif ($is_video_file && count($this->get_gather_types('video')) > 0) { 285 // // TODO $this->insert_video() 286 } elseif (!$is_audio_file && !$is_video_file) { 287 debug_event('seafile_catalog', 'read ' . $file->name . " ignored, unknown media file type", 5); 288 } else { 289 debug_event('seafile_catalog', 'read ' . $file->name . " ignored, bad media type for this catalog.", 290 5); 291 } 292 293 return 0; 294 }); 295 296 Ui::update_text(T_('Catalog Updated'), /* HINT: count of songs updated */ sprintf(T_('Total Media: [%s]'), 297 $count)); 298 299 if ($count < 1) { 300 AmpError::add('general', T_('No media was updated, did you respect the patterns?')); 301 } else { 302 $success = true; 303 } 304 } 305 306 if (!defined('SSE_OUTPUT')) { 307 Ui::show_box_bottom(); 308 } 309 310 $this->update_last_add(); 311 312 return $success; 313 } 314 315 /** 316 * _insert_local_song 317 * 318 * Insert a song that isn't already in the database. 319 * @param $file 320 * @return boolean|int 321 */ 322 private function insert_song($file) 323 { 324 if ($this->check_remote_song($this->seafile->to_virtual_path($file))) { 325 debug_event('seafile_catalog', 'Skipping existing song ' . $file->name, 5); 326 /* HINT: filename (File path) */ 327 Ui::update_text('', sprintf(T_('Skipping existing song: %s'), $file->name)); 328 } else { 329 debug_event('seafile_catalog', 'Adding song ' . $file->name, 5); 330 try { 331 $results = $this->download_metadata($file); 332 /* HINT: filename (File path) */ 333 Ui::update_text('', sprintf(T_('Adding a new song: %s'), $file->name)); 334 $added = Song::insert($results); 335 336 if ($added) { 337 $this->count++; 338 } 339 340 return $added; 341 } catch (Exception $error) { 342 /* HINT: %1 filename (File path), %2 error message */ 343 debug_event('seafile_catalog', 344 sprintf('Could not add song "%1$s": %2$s', $file->name, $error->getMessage()), 1); 345 /* HINT: filename (File path) */ 346 Ui::update_text('', sprintf(T_('Could not add song: %s'), $file->name)); 347 } 348 } 349 350 return false; 351 } 352 353 /** 354 * @param $file 355 * @param string $sort_pattern 356 * @param string $rename_pattern 357 * @param array $gather_types 358 * @return array 359 * @throws Exception 360 */ 361 private function download_metadata($file, $sort_pattern = '', $rename_pattern = '', $gather_types = null) 362 { 363 // Check for patterns 364 if (!$sort_pattern || !$rename_pattern) { 365 $sort_pattern = $this->sort_pattern; 366 $rename_pattern = $this->rename_pattern; 367 } 368 369 debug_event('seafile_catalog', 'Downloading partial song ' . $file->name, 5); 370 371 $tempfilename = $this->seafile->download($file, true); 372 373 if ($gather_types === null) { 374 $gather_types = $this->get_gather_types('music'); 375 } 376 377 $vainfo = $this->getUtilityFactory()->createVaInfo( 378 $tempfilename, 379 $gather_types, 380 '', 381 '', 382 $sort_pattern, 383 $rename_pattern, 384 true 385 ); 386 $vainfo->forceSize($file->size); 387 $vainfo->get_info(); 388 389 $key = VaInfo::get_tag_type($vainfo->tags); 390 391 // maybe fix stat-ing-nonexistent-file bug? 392 $vainfo->tags['general']['size'] = (int)($file->size); 393 394 $results = VaInfo::clean_tag_info($vainfo->tags, $key, $file->name); 395 396 // Set the remote path 397 $results['catalog'] = $this->id; 398 399 $results['file'] = $this->seafile->to_virtual_path($file); 400 401 return $results; 402 } 403 404 /** 405 * @return array 406 * @throws ReflectionException 407 */ 408 public function verify_catalog_proc() 409 { 410 $results = array('total' => 0, 'updated' => 0); 411 412 set_time_limit(0); 413 414 if ($this->seafile->prepare()) { 415 $sql = 'SELECT `id`, `file`, `title` FROM `song` WHERE `catalog` = ?'; 416 $db_results = Dba::read($sql, array($this->id)); 417 while ($row = Dba::fetch_assoc($db_results)) { 418 $results['total']++; 419 debug_event('seafile_catalog', 'Verify starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5, 420 'ampache-catalog'); 421 $fileinfo = $this->seafile->from_virtual_path($row['file']); 422 423 $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']); 424 425 $metadata = null; 426 427 if ($file !== null) { 428 $metadata = $this->download_metadata($file); 429 } 430 431 if ($metadata !== null) { 432 debug_event('seafile_catalog', 'Verify updating song', 5, 'ampache-catalog'); 433 $song = new Song($row['id']); 434 $info = ($song->id) ? self::update_song_from_tags($metadata, $song) : array(); 435 if ($info['change']) { 436 Ui::update_text('', sprintf(T_('Updated song: "%s"'), $row['title'])); 437 $results['updated']++; 438 } else { 439 Ui::update_text('', sprintf(T_('Song up to date: "%s"'), $row['title'])); 440 } 441 } else { 442 debug_event('seafile_catalog', 'Verify removing song', 5, 'ampache-catalog'); 443 Ui::update_text('', sprintf(T_('Removing song: "%s"'), $row['title'])); 444 //$dead++; 445 Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id'])); 446 } 447 } 448 449 $this->update_last_update(); 450 } 451 452 return $results; 453 } 454 455 /** 456 * @param Media $media 457 * @param array $gather_types 458 * @param string $sort_pattern 459 * @param string $rename_pattern 460 * @return array|null 461 * @throws Exception 462 */ 463 public function get_media_tags($media, $gather_types, $sort_pattern, $rename_pattern) 464 { 465 if ($this->seafile->prepare()) { 466 $fileinfo = $this->seafile->from_virtual_path($media->file); 467 468 $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']); 469 470 if ($file !== null) { 471 return $this->download_metadata($file, $sort_pattern, $rename_pattern, $gather_types); 472 } 473 } 474 475 return null; 476 } 477 478 /** 479 * clean_catalog_proc 480 * 481 * Removes songs that no longer exist. 482 */ 483 public function clean_catalog_proc() 484 { 485 $dead = 0; 486 487 set_time_limit(0); 488 489 if ($this->seafile->prepare()) { 490 $sql = 'SELECT `id`, `file` FROM `song` WHERE `catalog` = ?'; 491 $db_results = Dba::read($sql, array($this->id)); 492 while ($row = Dba::fetch_assoc($db_results)) { 493 debug_event('seafile_catalog', 'Clean starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5); 494 $file = $this->seafile->from_virtual_path($row['file']); 495 496 try { 497 $exists = $this->seafile->get_file($file['path'], $file['filename']) !== null; 498 } catch (Exception $error) { 499 Ui::update_text(T_("There Was a Problem"), 500 /* HINT: %1 filename (File path), %2 Error Message */ sprintf(T_('There was an error while checking this song "%1$s": %2$s'), 501 $file['filename'], $error->getMessage())); 502 debug_event('seafile_catalog', 'Clean Exception: ' . $error->getMessage(), 2); 503 504 continue; 505 } 506 507 if ($exists) { 508 debug_event('seafile_catalog', 'Clean keeping song', 5); 509 /* HINT: filename (File path) */ 510 Ui::update_text('', sprintf(T_('Keeping song: %s'), $file['filename'])); 511 } else { 512 /* HINT: filename (File path) */ 513 Ui::update_text('', sprintf(T_('Removing song: "%s"'), $file['filename'])); 514 debug_event('seafile_catalog', 'Clean removing song', 5); 515 $dead++; 516 Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id'])); 517 } 518 } 519 520 $this->update_last_clean(); 521 } 522 523 return $dead; 524 } 525 526 /** 527 * move_catalog_proc 528 * This function updates the file path of the catalog to a new location (unsupported) 529 * @param string $new_path 530 * @return boolean 531 */ 532 public function move_catalog_proc($new_path) 533 { 534 return false; 535 } 536 537 /** 538 * @return boolean 539 */ 540 public function cache_catalog_proc() 541 { 542 return false; 543 } 544 545 /** 546 * check_remote_song 547 * 548 * checks to see if a remote song exists in the database or not 549 * if it find a song it returns the UID 550 * @param $file 551 * @return boolean|mixed 552 */ 553 public function check_remote_song($file) 554 { 555 $sql = 'SELECT `id` FROM `song` WHERE `file` = ?'; 556 $db_results = Dba::read($sql, array($file)); 557 558 if ($results = Dba::fetch_assoc($db_results)) { 559 return $results['id']; 560 } 561 562 return false; 563 } 564 565 /** 566 * format 567 * 568 * This makes the object human-readable. 569 */ 570 public function format() 571 { 572 parent::format(); 573 574 if ($this->seafile != null) { 575 $this->f_info = $this->seafile->get_format_string(); 576 $this->f_full_info = $this->seafile->get_format_string(); 577 } else { 578 $this->f_info = "Seafile Catalog"; 579 $this->f_full_info = "Seafile Catalog"; 580 } 581 } 582 583 /** 584 * @param Podcast_Episode|Song|Song_Preview|Video $media 585 * @return Media|Podcast_Episode|Song|Song_Preview|Video|null 586 */ 587 public function prepare_media($media) 588 { 589 if ($this->seafile->prepare()) { 590 set_time_limit(0); 591 592 $fileinfo = $this->seafile->from_virtual_path($media->file); 593 594 $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']); 595 596 $tempfile = $this->seafile->download($file); 597 598 $media->file = $tempfile; 599 $media->f_file = $fileinfo['filename']; 600 601 // in case this didn't get set for some reason 602 if ($media->size == 0) { 603 $media->size = Core::get_filesize($tempfile); 604 } 605 } 606 607 return $media; 608 } 609 610 /** 611 * @deprecated Inject by constructor 612 */ 613 private function getUtilityFactory(): UtilityFactoryInterface 614 { 615 global $dic; 616 617 return $dic->get(UtilityFactoryInterface::class); 618 } 619} 620