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\System\Core; 27use Ampache\Repository\Model\Catalog; 28use Ampache\Repository\Model\Media; 29use Ampache\Repository\Model\Podcast_Episode; 30use Ampache\Repository\Model\Song; 31use Ampache\Repository\Model\Song_Preview; 32use Ampache\Repository\Model\Video; 33use Ampache\Module\System\AmpError; 34use Ampache\Module\System\Dba; 35use Ampache\Module\Util\Ui; 36use AmpacheApi; 37use Exception; 38 39/** 40 * This class handles all actual work in regards to remote catalogs. 41 */ 42class Catalog_remote extends Catalog 43{ 44 private $version = '000001'; 45 private $type = 'remote'; 46 private $description = 'Ampache Remote Catalog'; 47 48 /** 49 * get_description 50 * This returns the description of this catalog 51 */ 52 public function get_description() 53 { 54 return $this->description; 55 } // get_description 56 57 /** 58 * get_version 59 * This returns the current version 60 */ 61 public function get_version() 62 { 63 return $this->version; 64 } // get_version 65 66 /** 67 * get_type 68 * This returns the current catalog type 69 */ 70 public function get_type() 71 { 72 return $this->type; 73 } // get_type 74 75 /** 76 * get_create_help 77 * This returns hints on catalog creation 78 */ 79 public function get_create_help() 80 { 81 return ""; 82 } // get_create_help 83 84 /** 85 * is_installed 86 * This returns true or false if remote catalog is installed 87 */ 88 public function is_installed() 89 { 90 $sql = "SHOW TABLES LIKE 'catalog_remote'"; 91 $db_results = Dba::query($sql); 92 93 return (Dba::num_rows($db_results) > 0); 94 } // is_installed 95 96 /** 97 * install 98 * This function installs the remote catalog 99 */ 100 public function install() 101 { 102 $collation = (AmpConfig::get('database_collation', 'utf8mb4_unicode_ci')); 103 $charset = (AmpConfig::get('database_charset', 'utf8mb4')); 104 $engine = ($charset == 'utf8mb4') ? 'InnoDB' : 'MYISAM'; 105 106 $sql = "CREATE TABLE `catalog_remote` (`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `uri` VARCHAR(255) COLLATE $collation NOT NULL, `username` VARCHAR(255) COLLATE $collation NOT NULL, `password` VARCHAR(255) COLLATE $collation NOT NULL, `catalog_id` INT(11) NOT NULL) ENGINE = $engine DEFAULT CHARSET=$charset COLLATE=$collation"; 107 Dba::query($sql); 108 109 return true; 110 } // install 111 112 /** 113 * @return array 114 */ 115 public function catalog_fields() 116 { 117 $fields = array(); 118 119 $fields['uri'] = array('description' => T_('URI'), 'type' => 'url'); 120 $fields['username'] = array('description' => T_('Username'), 'type' => 'text'); 121 $fields['password'] = array('description' => T_('Password'), 'type' => 'password'); 122 123 return $fields; 124 } 125 126 public $uri; 127 public $username; 128 public $password; 129 130 /** 131 * Constructor 132 * 133 * Catalog class constructor, pulls catalog information 134 * @param integer $catalog_id 135 */ 136 public function __construct($catalog_id = null) 137 { 138 if ($catalog_id) { 139 $this->id = (int)($catalog_id); 140 $info = $this->get_info($catalog_id); 141 142 foreach ($info as $key => $value) { 143 $this->$key = $value; 144 } 145 } 146 } 147 148 /** 149 * create_type 150 * 151 * This creates a new catalog type entry for a catalog 152 * It checks to make sure its parameters is not already used before creating 153 * the catalog. 154 * @param $catalog_id 155 * @param array $data 156 * @return boolean 157 */ 158 public static function create_type($catalog_id, $data) 159 { 160 $uri = $data['uri']; 161 $username = $data['username']; 162 $password = $data['password']; 163 164 if (substr($uri, 0, 7) != 'http://' && substr($uri, 0, 8) != 'https://') { 165 AmpError::add('general', T_('Remote Catalog type was selected, but the path is not a URL')); 166 167 return false; 168 } 169 170 if (!strlen($username) || !strlen($password)) { 171 AmpError::add('general', T_('No username or password was specified')); 172 173 return false; 174 } 175 $password = hash('sha256', $password); 176 177 // Make sure this uri isn't already in use by an existing catalog 178 $sql = 'SELECT `id` FROM `catalog_remote` WHERE `uri` = ?'; 179 $db_results = Dba::read($sql, array($uri)); 180 181 if (Dba::num_rows($db_results)) { 182 debug_event('remote.catalog', 'Cannot add catalog with duplicate uri ' . $uri, 1); 183 /* HINT: remote URI */ 184 AmpError::add('general', sprintf(T_('This path belongs to an existing remote Catalog: %s'), $uri)); 185 186 return false; 187 } 188 189 $sql = 'INSERT INTO `catalog_remote` (`uri`, `username`, `password`, `catalog_id`) VALUES (?, ?, ?, ?)'; 190 Dba::write($sql, array($uri, $username, $password, $catalog_id)); 191 192 return true; 193 } 194 195 /** 196 * add_to_catalog 197 * this function adds new files to an 198 * existing catalog 199 * @param array $options 200 * @return boolean 201 * @throws Exception 202 */ 203 public function add_to_catalog($options = null) 204 { 205 if (!defined('SSE_OUTPUT')) { 206 Ui::show_box_top(T_('Running Remote Update')); 207 } 208 $this->update_remote_catalog(); 209 if (!defined('SSE_OUTPUT')) { 210 Ui::show_box_bottom(); 211 } 212 213 return true; 214 } // add_to_catalog 215 216 /** 217 * connect 218 * 219 * Connects to the remote catalog that we are. 220 */ 221 public function connect() 222 { 223 try { 224 $remote_handle = new AmpacheApi\AmpacheApi(array( 225 'username' => $this->username, 226 'password' => $this->password, 227 'server' => $this->uri, 228 'debug_callback' => 'debug_event', 229 'api_secure' => (substr($this->uri, 0, 8) == 'https://') 230 )); 231 } catch (Exception $error) { 232 debug_event('remote.catalog', 'Connection error: ' . $error->getMessage(), 1); 233 AmpError::add('general', $error->getMessage()); 234 echo AmpError::display('general'); 235 flush(); 236 237 return false; 238 } 239 240 if ($remote_handle->state() != 'CONNECTED') { 241 debug_event('remote.catalog', 'API client failed to connect', 1); 242 AmpError::add('general', T_('Failed to connect to the remote server')); 243 echo AmpError::display('general'); 244 245 return false; 246 } 247 248 return $remote_handle; 249 } 250 251 /** 252 * update_remote_catalog 253 * 254 * Pulls the data from a remote catalog and adds any missing songs to the 255 * database. 256 * @param integer $type 257 * @return boolean 258 * @throws Exception 259 */ 260 public function update_remote_catalog($type = 0) 261 { 262 set_time_limit(0); 263 264 $remote_handle = $this->connect(); 265 if (!$remote_handle) { 266 return false; 267 } 268 269 // Get the song count, etc. 270 $remote_catalog_info = $remote_handle->info(); 271 272 Ui::update_text(T_("Remote Catalog Updated"), /* HINT: count of songs found*/ sprintf(nT_('%s song was found', 273 '%s songs were found', $remote_catalog_info['songs']), $remote_catalog_info['songs'])); 274 275 // Hardcoded for now 276 $step = 500; 277 $current = 0; 278 $total = $remote_catalog_info['songs']; 279 280 while ($total > $current) { 281 $start = $current; 282 $current += $step; 283 try { 284 $songs = $remote_handle->send_command('songs', array('offset' => $start, 'limit' => $step)); 285 } catch (Exception $error) { 286 debug_event('remote.catalog', 'Songs parsing error: ' . $error->getMessage(), 1); 287 AmpError::add('general', $error->getMessage()); 288 echo AmpError::display('general'); 289 flush(); 290 } 291 292 // Iterate over the songs we retrieved and insert them 293 foreach ($songs as $data) { 294 if ($this->check_remote_song($data['song'])) { 295 debug_event('remote.catalog', 'Skipping existing song ' . $data['song']['url'], 5); 296 } else { 297 $data['song']['catalog'] = $this->id; 298 $data['song']['file'] = preg_replace('/ssid=.*?&/', '', $data['song']['url']); 299 if (!Song::insert($data['song'])) { 300 debug_event('remote.catalog', 'Insert failed for ' . $data['song']['self']['id'], 1); 301 /* HINT: Song Title */ 302 AmpError::add('general', T_('Unable to insert song - %s'), $data['song']['title']); 303 echo AmpError::display('general'); 304 flush(); 305 } 306 } 307 } 308 } // end while 309 310 Ui::update_text(T_("Updated"), T_("Completed updating remote Catalog(s).")); 311 312 // Update the last update value 313 $this->update_last_update(); 314 315 return true; 316 } 317 318 /** 319 * @return array 320 */ 321 public function verify_catalog_proc() 322 { 323 return array('total' => 0, 'updated' => 0); 324 } 325 326 /** 327 * clean_catalog_proc 328 * 329 * Removes remote songs that no longer exist. 330 */ 331 public function clean_catalog_proc() 332 { 333 $remote_handle = $this->connect(); 334 if (!$remote_handle) { 335 debug_event('remote.catalog', 'Remote login failed', 1, 'ampache-catalog'); 336 337 return 0; 338 } 339 340 $dead = 0; 341 342 $sql = 'SELECT `id`, `file` FROM `song` WHERE `catalog` = ?'; 343 $db_results = Dba::read($sql, array($this->id)); 344 while ($row = Dba::fetch_assoc($db_results)) { 345 debug_event('remote.catalog', 'Starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5, 346 'ampache-catalog'); 347 try { 348 $song = $remote_handle->send_command('url_to_song', array('url' => $row['file'])); 349 } catch (Exception $error) { 350 // FIXME: What to do, what to do 351 debug_event('remote.catalog', 'url_to_song parsing error: ' . $error->getMessage(), 1); 352 } 353 354 if (count($song) == 1) { 355 debug_event('remote.catalog', 'keeping song', 5, 'ampache-catalog'); 356 } else { 357 debug_event('remote.catalog', 'removing song', 5, 'ampache-catalog'); 358 $dead++; 359 Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id'])); 360 } 361 } 362 363 return $dead; 364 } 365 366 /** 367 * move_catalog_proc 368 * This function updates the file path of the catalog to a new location (unsupported) 369 * @param string $new_path 370 * @return boolean 371 */ 372 public function move_catalog_proc($new_path) 373 { 374 return false; 375 } 376 377 /** 378 * @return boolean 379 */ 380 public function cache_catalog_proc() 381 { 382 $remote_handle = $this->connect(); 383 384 // If we don't get anything back we failed and should bail now 385 if (!$remote_handle) { 386 debug_event('remote.catalog', 'Connection to remote server failed', 1); 387 388 return false; 389 } 390 391 $remote = AmpConfig::get('cache_remote'); 392 $path = (string)AmpConfig::get('cache_path', ''); 393 $target = AmpConfig::get('cache_target'); 394 // need a destination, source and target format 395 if (!is_dir($path) || !$remote || !$target) { 396 debug_event('remote.catalog', 'Check your cache_path cache_target and cache_remote settings', 5); 397 398 return false; 399 } 400 // make a folder per catalog 401 if (!is_dir(rtrim(trim($path), '/') . '/' . $this->id)) { 402 mkdir(rtrim(trim($path), '/') . '/' . $this->id, 0777, true); 403 } 404 $max_bitrate = (int)AmpConfig::get('max_bit_rate', 128); 405 $user_bit_rate = (int)AmpConfig::get('transcode_bitrate', 128); 406 407 // If the user's crazy, that's no skin off our back 408 if ($user_bit_rate > $max_bitrate) { 409 $max_bitrate = $user_bit_rate; 410 } 411 $handshake = $remote_handle->info(); 412 $sql = "SELECT `id`, `file`, substring_index(file,'.',-1) as `extension` FROM `song` WHERE `catalog` = ?;"; 413 $db_results = Dba::read($sql, array($this->id)); 414 while ($row = Dba::fetch_assoc($db_results)) { 415 $target_file = rtrim(trim($path), '/') . '/' . $this->id . '/' . $row['id'] . '.' . $row['extension']; 416 $remote_url = $row['file'] . '&ssid=' . $handshake['auth'] . '&format=' . $target . '&bitrate=' . $max_bitrate; 417 if (!is_file($target_file) || (int)Core::get_filesize($target_file) == 0) { 418 debug_event('remote.catalog', 'Saving ' . $row['id'] . ' to (' . $target_file . ')', 5); 419 try { 420 $filehandle = fopen($target_file, 'w'); 421 $options = array( 422 CURLOPT_RETURNTRANSFER => 1, 423 CURLOPT_FILE => $filehandle, 424 CURLOPT_TIMEOUT => 0, 425 CURLOPT_PIPEWAIT => 1, 426 CURLOPT_URL => $remote_url, 427 ); 428 $curl = curl_init(); 429 curl_setopt_array($curl, $options); 430 curl_exec($curl); 431 curl_close($curl); 432 fclose($filehandle); 433 debug_event('remote.catalog', 'Saved: ' . $row['id'] . ' to: {' . $target_file . '}', 5); 434 } catch (Exception $error) { 435 debug_event('remote.catalog', 'Cache error: ' . $row['id'] . ' ' . $error->getMessage(), 5); 436 } 437 // keep alive just in case 438 $remote_handle->send_command('ping'); 439 } 440 } 441 442 return true; 443 } 444 445 /** 446 * check_remote_song 447 * 448 * checks to see if a remote song exists in the database or not 449 * if it find a song it returns the UID 450 * @param array $song 451 * @return boolean|mixed 452 */ 453 public function check_remote_song($song) 454 { 455 $url = preg_replace('/ssid=.*&/', '', $song['url']); 456 457 $sql = 'SELECT `id` FROM `song` WHERE `file` = ?'; 458 $db_results = Dba::read($sql, array($url)); 459 460 if ($results = Dba::fetch_assoc($db_results)) { 461 return $results['id']; 462 } 463 464 return false; 465 } 466 467 /** 468 * @param string $file_path 469 * @return string|string[] 470 */ 471 public function get_rel_path($file_path) 472 { 473 $catalog_path = rtrim($this->uri, "/"); 474 475 return (str_replace($catalog_path . "/", "", $file_path)); 476 } 477 478 /** 479 * format 480 * 481 * This makes the object human-readable. 482 */ 483 public function format() 484 { 485 parent::format(); 486 $this->f_info = $this->uri; 487 $this->f_full_info = $this->uri; 488 } 489 490 /** 491 * @param Podcast_Episode|Song|Song_Preview|Video $media 492 * @return boolean|null 493 * @throws Exception 494 */ 495 public function prepare_media($media) 496 { 497 $remote_handle = $this->connect(); 498 499 // If we don't get anything back we failed and should bail now 500 if (!$remote_handle) { 501 debug_event('remote.catalog', 'Connection to remote server failed', 1); 502 503 return false; 504 } 505 506 $handshake = $remote_handle->info(); 507 $url = $media->file . '&ssid=' . $handshake['auth']; 508 509 header('Location: ' . $url); 510 debug_event('remote.catalog', 'Started remote stream - ' . $url, 5); 511 512 return null; 513 } 514} 515