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\Art; 28use Ampache\Repository\Model\Catalog; 29use Ampache\Repository\Model\Media; 30use Ampache\Repository\Model\Podcast_Episode; 31use Ampache\Repository\Model\Song; 32use Ampache\Repository\Model\Song_Preview; 33use Ampache\Repository\Model\Video; 34use Ampache\Module\System\AmpError; 35use Ampache\Module\System\Dba; 36use Ampache\Module\Util\Ui; 37use Exception; 38 39/** 40 * This class handles all actual work in regards to remote Subsonic catalogs. 41 */ 42class Catalog_subsonic extends Catalog 43{ 44 private $version = '000002'; 45 private $type = 'subsonic'; 46 private $description = 'Subsonic 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_subsonic'"; 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_subsonic` (`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 176 // Make sure this uri isn't already in use by an existing catalog 177 $sql = 'SELECT `id` FROM `catalog_subsonic` WHERE `uri` = ?'; 178 $db_results = Dba::read($sql, array($uri)); 179 180 if (Dba::num_rows($db_results)) { 181 debug_event('subsonic.catalog', 'Cannot add catalog with duplicate uri ' . $uri, 1); 182 /* HINT: subsonic catalog URI */ 183 AmpError::add('general', sprintf(T_('This path belongs to an existing Subsonic Catalog: %s'), $uri)); 184 185 return false; 186 } 187 188 $sql = 'INSERT INTO `catalog_subsonic` (`uri`, `username`, `password`, `catalog_id`) VALUES (?, ?, ?, ?)'; 189 Dba::write($sql, array($uri, $username, $password, $catalog_id)); 190 191 return true; 192 } 193 194 /** 195 * add_to_catalog 196 * this function adds new files to an 197 * existing catalog 198 * @param array $options 199 * @return boolean 200 */ 201 public function add_to_catalog($options = null) 202 { 203 // Prevent the script from timing out 204 set_time_limit(0); 205 206 if (!defined('SSE_OUTPUT')) { 207 Ui::show_box_top(T_('Running Subsonic Remote Update')); 208 } 209 $this->update_remote_catalog(); 210 if (!defined('SSE_OUTPUT')) { 211 Ui::show_box_bottom(); 212 } 213 214 return true; 215 } // add_to_catalog 216 217 /** 218 * @return SubsonicClient 219 */ 220 public function createClient() 221 { 222 return (new SubsonicClient($this->username, $this->password, $this->uri, null)); 223 } 224 225 /** 226 * update_remote_catalog 227 * 228 * Pulls the data from a remote catalog and adds any missing songs to the 229 * database. 230 */ 231 public function update_remote_catalog() 232 { 233 debug_event('subsonic.catalog', 'Updating remote catalog...', 5); 234 235 $subsonic = $this->createClient(); 236 237 $songsadded = 0; 238 // Get all albums 239 $offset = 0; 240 while (true) { 241 $albumList = $subsonic->querySubsonic('getAlbumList', 242 ['type' => 'alphabeticalByName', 'size' => 500, 'offset' => $offset]); 243 $offset += 500; 244 if ($albumList['success']) { 245 if (count($albumList['data']['albumList']) == 0) { 246 break; 247 } 248 foreach ($albumList['data']['albumList']['album'] as $anAlbum) { 249 $album = $subsonic->querySubsonic('getMusicDirectory', ['id' => $anAlbum['id']]); 250 251 if ($album['success']) { 252 foreach ($album['data']['directory']['child'] as $song) { 253 $artistInfo = $subsonic->querySubsonic('getArtistInfo', ['id' => $song['artistId']]); 254 if (Catalog::is_audio_file($song['path'])) { 255 $data = array(); 256 $data['artist'] = html_entity_decode($song['artist']); 257 $data['album'] = html_entity_decode($song['album']); 258 $data['title'] = html_entity_decode($song['title']); 259 if ($artistInfo['Success']) { 260 $data['comment'] = html_entity_decode($artistInfo['data']['artistInfo']['biography']); 261 } 262 $data['year'] = $song['year']; 263 $data['bitrate'] = $song['bitRate'] * 1000; 264 $data['size'] = $song['size']; 265 $data['time'] = $song['duration']; 266 $data['track'] = $song['track']; 267 $data['disk'] = $song['discNumber']; 268 $data['coverArt'] = $song['coverArt']; 269 $data['mode'] = 'vbr'; 270 $data['genre'] = explode(' ', html_entity_decode($song['genre'])); 271 $data['file'] = $this->uri . '/rest/stream.view?id=' . $song['id'] . '&filename=' . urlencode($song['path']); 272 if ($this->check_remote_song($data)) { 273 debug_event('subsonic.catalog', 'Skipping existing song ' . $data['path'], 5); 274 } else { 275 $data['catalog'] = $this->id; 276 debug_event('subsonic.catalog', 'Adding song ' . $song['path'], 5, 277 'ampache-catalog'); 278 $song_Id = Song::insert($data); 279 if (!$song_Id) { 280 debug_event('subsonic.catalog', 'Insert failed for ' . $song['path'], 1); 281 /* HINT: filename (file path) */ 282 AmpError::add('general', T_('Unable to insert song - %s'), $song['path']); 283 } else { 284 if ($song['coverArt']) { 285 $this->insertArt($song, $song_Id); 286 } 287 } 288 $songsadded++; 289 } 290 } 291 } 292 } 293 } 294 } else { 295 break; 296 } 297 } 298 299 Ui::update_text(T_("Updated"), 300 T_('Completed updating Subsonic Catalog(s)') . " " . /* HINT: Number of songs */ sprintf(nT_('%s Song added', 301 '%s Songs added', $songsadded), $songsadded)); 302 303 // Update the last update value 304 $this->update_last_update(); 305 306 debug_event('subsonic.catalog', 'Catalog updated.', 4); 307 308 return true; 309 } 310 311 /** 312 * @return array 313 */ 314 public function verify_catalog_proc() 315 { 316 return array('total' => 0, 'updated' => 0); 317 } 318 319 /** 320 * @param $data 321 * @param $song_Id 322 * @return boolean 323 */ 324 public function insertArt($data, $song_Id) 325 { 326 $subsonic = $this->createClient(); 327 $song = new Song($song_Id); 328 $art = new Art($song->album, 'album'); 329 if (AmpConfig::get('album_art_max_height') && AmpConfig::get('album_art_max_width')) { 330 $size = array( 331 'width' => AmpConfig::get('album_art_max_width'), 332 'height' => AmpConfig::get('album_art_max_height') 333 ); 334 } else { 335 $size = array('width' => 275, 'height' => 275); 336 } 337 $image = $subsonic->querySubsonic('getCoverArt', ['id' => $data['coverArt'], $size], true); 338 339 return $art->insert($image); 340 } 341 342 /** 343 * clean_catalog_proc 344 * 345 * Removes subsonic songs that no longer exist. 346 */ 347 public function clean_catalog_proc() 348 { 349 $subsonic = $this->createClient(); 350 351 $dead = 0; 352 353 $sql = 'SELECT `id`, `file` FROM `song` WHERE `catalog` = ?'; 354 $db_results = Dba::read($sql, array($this->id)); 355 while ($row = Dba::fetch_assoc($db_results)) { 356 debug_event('subsonic.catalog', 'Starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5, 357 'ampache-catalog'); 358 $remove = false; 359 try { 360 $songid = $this->url_to_songid($row['file']); 361 $song = $subsonic->getSong(array('id' => $songid)); 362 if (!$song['success']) { 363 $remove = true; 364 } 365 } catch (Exception $error) { 366 debug_event('subsonic.catalog', 'Clean error: ' . $error->getMessage(), 5); 367 } 368 369 if (!$remove) { 370 debug_event('subsonic.catalog', 'keeping song', 5); 371 } else { 372 debug_event('subsonic.catalog', 'removing song', 5); 373 $dead++; 374 Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id'])); 375 } 376 } 377 378 return $dead; 379 } 380 381 /** 382 * move_catalog_proc 383 * This function updates the file path of the catalog to a new location (unsupported) 384 * @param string $new_path 385 * @return boolean 386 */ 387 public function move_catalog_proc($new_path) 388 { 389 return false; 390 } 391 392 /** 393 * @return boolean 394 */ 395 public function cache_catalog_proc() 396 { 397 $remote = AmpConfig::get('cache_remote'); 398 $path = (string)AmpConfig::get('cache_path', ''); 399 $target = AmpConfig::get('cache_target'); 400 // need a destination, source and target format 401 if (!is_dir($path) || !$remote || !$target) { 402 debug_event('local.catalog', 'Check your cache_path cache_target and cache_remote settings', 5); 403 404 return false; 405 } 406 // make a folder per catalog 407 if (!is_dir(rtrim(trim($path), '/') . '/' . $this->id)) { 408 mkdir(rtrim(trim($path), '/') . '/' . $this->id, 0777, true); 409 } 410 $max_bitrate = (int)AmpConfig::get('max_bit_rate', 128); 411 $user_bit_rate = (int)AmpConfig::get('transcode_bitrate', 128); 412 413 // If the user's crazy, that's no skin off our back 414 if ($user_bit_rate > $max_bitrate) { 415 $max_bitrate = $user_bit_rate; 416 } 417 $options = array( 418 'format' => $target, 419 'maxBitRate' => $max_bitrate 420 ); 421 $subsonic = $this->createClient(); 422 $sql = "SELECT `id`, `file` FROM `song` WHERE `catalog` = ?;"; 423 $db_results = Dba::read($sql, array($this->id)); 424 while ($row = Dba::fetch_assoc($db_results)) { 425 $target_file = rtrim(trim($path), '/') . '/' . $this->id . '/' . $row['id'] . '.' . $target; 426 $remote_url = $subsonic->parameterize($row['file'] . '&', $options); 427 if (!is_file($target_file) || (int)Core::get_filesize($target_file) == 0) { 428 try { 429 $filehandle = fopen($target_file, 'w'); 430 $options = array( 431 CURLOPT_RETURNTRANSFER => 1, 432 CURLOPT_FILE => $filehandle, 433 CURLOPT_TIMEOUT => 0, 434 CURLOPT_PIPEWAIT => 1, 435 CURLOPT_URL => $remote_url, 436 ); 437 $curl = curl_init(); 438 curl_setopt_array($curl, $options); 439 curl_exec($curl); 440 curl_close($curl); 441 fclose($filehandle); 442 debug_event('subsonic.catalog', 'Saved: ' . $row['id'] . ' to: {' . $target_file . '}', 5); 443 } catch (Exception $error) { 444 debug_event('subsonic.catalog', 'Cache error: ' . $row['id'] . ' ' . $error->getMessage(), 5); 445 } 446 } 447 } 448 449 return true; 450 } 451 452 /** 453 * check_remote_song 454 * 455 * checks to see if a remote song exists in the database or not 456 * if it find a song it returns the UID 457 * @param array $song 458 * @return boolean|mixed 459 */ 460 public function check_remote_song($song) 461 { 462 $url = $song['file']; 463 464 $sql = 'SELECT `id` FROM `song` WHERE `file` = ?'; 465 $db_results = Dba::read($sql, array($url)); 466 467 if ($results = Dba::fetch_assoc($db_results)) { 468 return $results['id']; 469 } 470 471 return false; 472 } 473 474 /** 475 * @param string $file_path 476 * @return string|string[] 477 */ 478 public function get_rel_path($file_path) 479 { 480 $catalog_path = rtrim($this->uri, "/"); 481 482 return (str_replace($catalog_path . "/", "", $file_path)); 483 } 484 485 /** 486 * @param $url 487 * @return integer 488 */ 489 public function url_to_songid($url) 490 { 491 $song_id = 0; 492 preg_match('/\?id=([0-9]*)&/', $url, $matches); 493 if (count($matches)) { 494 $song_id = $matches[1]; 495 } 496 497 return $song_id; 498 } 499 500 /** 501 * format 502 * 503 * This makes the object human-readable. 504 */ 505 public function format() 506 { 507 parent::format(); 508 $this->f_info = $this->uri; 509 $this->f_full_info = $this->uri; 510 } 511 512 /** 513 * @param Podcast_Episode|Song|Song_Preview|Video $media 514 * @return Media|null 515 */ 516 public function prepare_media($media) 517 { 518 $subsonic = $this->createClient(); 519 $url = $subsonic->parameterize($media->file . '&'); 520 521 header('Location: ' . $url); 522 debug_event('subsonic.catalog', 'Started remote stream - ' . $url, 5); 523 524 return null; 525 } 526} 527