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 22declare(strict_types=0); 23 24namespace Ampache\Repository\Model; 25 26use Ampache\Module\System\Dba; 27use Ampache\Config\AmpConfig; 28use Ampache\Module\System\AmpError; 29use Ampache\Module\System\Core; 30use PDOStatement; 31use SimpleXMLElement; 32 33class Podcast extends database_object implements library_item 34{ 35 protected const DB_TABLENAME = 'podcast'; 36 37 /* Variables from DB */ 38 public $id; 39 public $catalog; 40 public $feed; 41 public $title; 42 public $website; 43 public $description; 44 public $language; 45 public $copyright; 46 public $generator; 47 public $lastbuilddate; 48 public $lastsync; 49 public $total_count; 50 public $episodes; 51 52 public $f_title; 53 public $f_website; 54 public $f_description; 55 public $f_language; 56 public $f_copyright; 57 public $f_generator; 58 public $f_lastbuilddate; 59 public $f_lastsync; 60 public $link; 61 public $f_link; 62 public $f_website_link; 63 64 /** 65 * Podcast 66 * Takes the ID of the podcast and pulls the info from the db 67 * @param integer $podcast_id 68 */ 69 public function __construct($podcast_id = 0) 70 { 71 /* If they failed to pass in an id, just run for it */ 72 if (!$podcast_id) { 73 return false; 74 } 75 76 /* Get the information from the db */ 77 $info = $this->get_info($podcast_id); 78 79 foreach ($info as $key => $value) { 80 $this->$key = $value; 81 } // foreach info 82 83 return true; 84 } // constructor 85 86 public function getId(): int 87 { 88 return (int) $this->id; 89 } 90 91 /** 92 * get_catalogs 93 * 94 * Get all catalog ids related to this item. 95 * @return integer[] 96 */ 97 public function get_catalogs() 98 { 99 return array($this->catalog); 100 } 101 102 /** 103 * get_episodes 104 * gets all episodes for this podcast 105 * @param string $state_filter 106 * @return array 107 */ 108 public function get_episodes($state_filter = '') 109 { 110 $params = array(); 111 $sql = "SELECT `podcast_episode`.`id` FROM `podcast_episode` "; 112 $catalog_disable = AmpConfig::get('catalog_disable'); 113 if ($catalog_disable) { 114 $sql .= "LEFT JOIN `catalog` ON `catalog`.`id` = `podcast_episode`.`catalog` "; 115 } 116 $sql .= "WHERE `podcast_episode`.`podcast`='" . Dba::escape($this->id) . "' "; 117 if (!empty($state_filter)) { 118 $sql .= "AND `podcast_episode`.`state` = ? "; 119 $params[] = $state_filter; 120 } 121 if ($catalog_disable) { 122 $sql .= "AND `catalog`.`enabled` = '1' "; 123 } 124 $sql .= "ORDER BY `podcast_episode`.`pubdate` DESC"; 125 $db_results = Dba::read($sql, $params); 126 127 $results = array(); 128 while ($row = Dba::fetch_assoc($db_results)) { 129 $results[] = $row['id']; 130 } 131 132 return $results; 133 } // get_episodes 134 135 /** 136 * format 137 * this function takes the object and formats some values 138 * @param boolean $details 139 * @return boolean 140 */ 141 public function format($details = true) 142 { 143 $this->f_title = scrub_out($this->title); 144 $this->f_description = scrub_out($this->description); 145 $this->f_language = scrub_out($this->language); 146 $this->f_copyright = scrub_out($this->copyright); 147 $this->f_generator = scrub_out($this->generator); 148 $this->f_website = scrub_out($this->website); 149 $this->f_lastbuilddate = date("c", (int)$this->lastbuilddate); 150 $this->f_lastsync = date("c", (int)$this->lastsync); 151 $this->link = AmpConfig::get('web_path') . '/podcast.php?action=show&podcast=' . $this->id; 152 $this->f_link = '<a href="' . $this->link . '" title="' . scrub_out($this->f_title) . '">' . scrub_out($this->f_title) . '</a>'; 153 $this->f_website_link = "<a target=\"_blank\" href=\"" . $this->website . "\">" . $this->website . "</a>"; 154 155 return true; 156 } 157 158 /** 159 * get_keywords 160 * @return array 161 */ 162 public function get_keywords() 163 { 164 $keywords = array(); 165 $keywords['podcast'] = array( 166 'important' => true, 167 'label' => T_('Podcast'), 168 'value' => $this->f_title 169 ); 170 171 return $keywords; 172 } 173 174 /** 175 * get_fullname 176 * 177 * @return string 178 */ 179 public function get_fullname() 180 { 181 return $this->f_title; 182 } 183 184 /** 185 * @return null 186 */ 187 public function get_parent() 188 { 189 return null; 190 } 191 192 /** 193 * @return array 194 */ 195 public function get_childrens() 196 { 197 return array('podcast_episode' => $this->get_episodes()); 198 } 199 200 /** 201 * @param string $name 202 * @return array 203 */ 204 public function search_childrens($name) 205 { 206 debug_event(self::class, 'search_childrens ' . $name, 5); 207 208 return array(); 209 } 210 211 /** 212 * @param string $filter_type 213 * @return array 214 */ 215 public function get_medias($filter_type = null) 216 { 217 $medias = array(); 218 if ($filter_type === null || $filter_type == 'podcast_episode') { 219 $episodes = $this->get_episodes('completed'); 220 foreach ($episodes as $episode_id) { 221 $medias[] = array( 222 'object_type' => 'podcast_episode', 223 'object_id' => $episode_id 224 ); 225 } 226 } 227 228 return $medias; 229 } 230 231 /** 232 * @return mixed|null 233 */ 234 public function get_user_owner() 235 { 236 return null; 237 } 238 239 /** 240 * @return string 241 */ 242 public function get_default_art_kind() 243 { 244 return 'default'; 245 } 246 247 /** 248 * get_description 249 * @return string 250 */ 251 public function get_description() 252 { 253 return $this->f_description; 254 } 255 256 /** 257 * display_art 258 * @param integer $thumb 259 * @param boolean $force 260 */ 261 public function display_art($thumb = 2, $force = false) 262 { 263 if (Art::has_db($this->id, 'podcast') || $force) { 264 Art::display('podcast', $this->id, $this->get_fullname(), $thumb, $this->link); 265 } 266 } 267 268 /** 269 * update 270 * This takes a key'd array of data and updates the current podcast 271 * @param array $data 272 * @return mixed 273 */ 274 public function update(array $data) 275 { 276 $feed = isset($data['feed']) ? $data['feed'] : $this->feed; 277 $title = isset($data['title']) ? scrub_in($data['title']) : $this->title; 278 $website = isset($data['website']) ? scrub_in($data['website']) : $this->website; 279 $description = isset($data['description']) ? scrub_in($data['description']) : $this->description; 280 $generator = isset($data['generator']) ? scrub_in($data['generator']) : $this->generator; 281 $copyright = isset($data['copyright']) ? scrub_in($data['copyright']) : $this->copyright; 282 283 if (strpos($feed, "http://") !== 0 && strpos($feed, "https://") !== 0) { 284 debug_event(self::class, 'Podcast update canceled, bad feed url.', 1); 285 286 return $this->id; 287 } 288 289 $sql = 'UPDATE `podcast` SET `feed` = ?, `title` = ?, `website` = ?, `description` = ?, `generator` = ?, `copyright` = ? WHERE `id` = ?'; 290 Dba::write($sql, array($feed, $title, $website, $description, $generator, $copyright, $this->id)); 291 292 $this->feed = $feed; 293 $this->title = $title; 294 $this->website = $website; 295 $this->description = $description; 296 $this->generator = $generator; 297 $this->copyright = $copyright; 298 299 return $this->id; 300 } 301 302 /** 303 * create 304 * @param array $data 305 * @param boolean $return_id 306 * @return boolean|integer 307 */ 308 public static function create(array $data, $return_id = false) 309 { 310 $feed = (string) $data['feed']; 311 // Feed must be http/https 312 if (strpos($feed, "http://") !== 0 && strpos($feed, "https://") !== 0) { 313 AmpError::add('feed', T_('Feed URL is invalid')); 314 } 315 316 $catalog_id = (int)($data['catalog']); 317 if ($catalog_id < 1) { 318 AmpError::add('catalog', T_('Target Catalog is required')); 319 } else { 320 $catalog = Catalog::create_from_id($catalog_id); 321 if ($catalog->gather_types !== "podcast") { 322 AmpError::add('catalog', T_('Wrong target Catalog type')); 323 } 324 } 325 326 if (AmpError::occurred()) { 327 return false; 328 } 329 330 $title = T_('Unknown'); 331 $website = null; 332 $description = null; 333 $language = null; 334 $copyright = null; 335 $generator = null; 336 $lastbuilddate = 0; 337 $episodes = false; 338 $arturl = ''; 339 340 // don't allow duplicate podcasts 341 $sql = "SELECT `id` FROM `podcast` WHERE `feed`= '" . Dba::escape($feed) . "'"; 342 $db_results = Dba::read($sql); 343 while ($row = Dba::fetch_assoc($db_results, false)) { 344 if ((int) $row['id'] > 0) { 345 return (int) $row['id']; 346 } 347 } 348 349 $xmlstr = file_get_contents($feed, false, stream_context_create(Core::requests_options())); 350 if ($xmlstr === false) { 351 AmpError::add('feed', T_('Can not access the feed')); 352 } else { 353 $xml = simplexml_load_string($xmlstr); 354 if ($xml === false) { 355 AmpError::add('feed', T_('Can not read the feed')); 356 } else { 357 $title = html_entity_decode((string)$xml->channel->title); 358 $website = (string)$xml->channel->link; 359 $description = html_entity_decode((string)$xml->channel->description); 360 $language = (string)$xml->channel->language; 361 $copyright = html_entity_decode((string)$xml->channel->copyright); 362 $generator = html_entity_decode((string)$xml->channel->generator); 363 $lastbuilddatestr = (string)$xml->channel->lastBuildDate; 364 if ($lastbuilddatestr) { 365 $lastbuilddate = strtotime($lastbuilddatestr); 366 } 367 368 if ($xml->channel->image) { 369 $arturl = (string)$xml->channel->image->url; 370 } 371 372 $episodes = $xml->channel->item; 373 } 374 } 375 376 if (AmpError::occurred()) { 377 return false; 378 } 379 380 $sql = "INSERT INTO `podcast` (`feed`, `catalog`, `title`, `website`, `description`, `language`, `copyright`, `generator`, `lastbuilddate`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; 381 $db_results = Dba::write($sql, array( 382 $feed, 383 $catalog_id, 384 $title, 385 $website, 386 $description, 387 $language, 388 $copyright, 389 $generator, 390 $lastbuilddate 391 )); 392 if ($db_results) { 393 $podcast_id = (int)Dba::insert_id(); 394 $podcast = new Podcast($podcast_id); 395 $dirpath = $podcast->get_root_path(); 396 if (!is_dir($dirpath)) { 397 if (mkdir($dirpath) === false) { 398 debug_event(self::class, 'Cannot create directory ' . $dirpath, 1); 399 } 400 } 401 if (!empty($arturl)) { 402 $art = new Art((int)$podcast_id, 'podcast'); 403 $art->insert_url($arturl); 404 } 405 Catalog::update_map($catalog_id, 'podcast', (int)$podcast_id); 406 if ($episodes) { 407 $podcast->add_episodes($episodes); 408 } 409 if ($return_id) { 410 return (int)$podcast_id; 411 } 412 413 return true; 414 } 415 416 return false; 417 } 418 419 /** 420 * add_episodes 421 * @param SimpleXMLElement $episodes 422 * @param integer $afterdate 423 * @param boolean $gather 424 */ 425 public function add_episodes($episodes, $afterdate = 0, $gather = false) 426 { 427 foreach ($episodes as $episode) { 428 $this->add_episode($episode, $afterdate); 429 } 430 $time = time(); 431 $params = array($this->id); 432 433 // Select episodes to download 434 $dlnb = (int)AmpConfig::get('podcast_new_download'); 435 if ($dlnb <> 0) { 436 $sql = "SELECT `podcast_episode`.`id` FROM `podcast_episode` INNER JOIN `podcast` ON `podcast`.`id` = `podcast_episode`.`podcast` WHERE `podcast`.`id` = ? AND `podcast_episode`.`addition_time` > `podcast`.`lastsync` ORDER BY `podcast_episode`.`pubdate` DESC"; 437 if ($dlnb > 0) { 438 $sql .= " LIMIT " . (string)$dlnb; 439 } 440 $db_results = Dba::read($sql, $params); 441 while ($row = Dba::fetch_row($db_results)) { 442 $episode = new Podcast_Episode($row[0]); 443 $episode->change_state('pending'); 444 if ($gather) { 445 $episode->gather(); 446 } 447 } 448 } 449 // Remove items outside limit 450 $keepnb = AmpConfig::get('podcast_keep'); 451 if ($keepnb > 0) { 452 $sql = "SELECT `podcast_episode`.`id` FROM `podcast_episode` WHERE `podcast_episode`.`podcast` = ? ORDER BY `podcast_episode`.`pubdate` DESC LIMIT " . $keepnb . ",18446744073709551615"; 453 $db_results = Dba::read($sql, $params); 454 while ($row = Dba::fetch_row($db_results)) { 455 $episode = new Podcast_Episode($row[0]); 456 $episode->remove(); 457 } 458 } 459 // update the episode count after adding / removing episodes 460 $sql = "UPDATE `podcast`, (SELECT COUNT(`podcast_episode`.`id`) AS `episodes`, `podcast` FROM `podcast_episode` WHERE `podcast_episode`.`podcast` = ? GROUP BY `podcast_episode`.`podcast`) AS `episode_count` SET `podcast`.`episodes` = `episode_count`.`episodes` WHERE `podcast`.`episodes` != `episode_count`.`episodes` AND `podcast`.`id` = `episode_count`.`podcast`;"; 461 Dba::write($sql, $params); 462 Catalog::update_mapping('podcast_episode'); 463 $this->update_lastsync($time); 464 } 465 466 /** 467 * add_episode 468 * @param SimpleXMLElement $episode 469 * @param integer $afterdate 470 * @return PDOStatement|boolean 471 */ 472 private function add_episode(SimpleXMLElement $episode, $afterdate = 0) 473 { 474 debug_event(self::class, 'Adding new episode to podcast ' . $this->id . '...', 4); 475 476 $title = html_entity_decode((string)$episode->title); 477 $website = (string)$episode->link; 478 $guid = (string)$episode->guid; 479 $description = html_entity_decode((string)$episode->description); 480 $author = html_entity_decode((string)$episode->author); 481 $category = html_entity_decode((string)$episode->category); 482 $source = null; 483 $time = 0; 484 if ($episode->enclosure) { 485 $source = $episode->enclosure['url']; 486 } 487 $itunes = $episode->children('itunes', true); 488 $duration = (string) $itunes->duration; 489 // time is missing hour e.g. "15:23" 490 if (preg_grep("/^[0-9][0-9]\:[0-9][0-9]$/", array($duration))) { 491 $duration = '00:' . $duration; 492 } 493 // process a time string "03:23:01" 494 $ptime = (preg_grep("/[0-9][0-9]\:[0-9][0-9]\:[0-9][0-9]/", array($duration))) 495 ? date_parse((string)$duration) 496 : $duration; 497 // process "HH:MM:SS" time OR fall back to a seconds duration string e.g "24325" 498 $time = (is_array($ptime)) 499 ? (int) $ptime['hour'] * 3600 + (int) $ptime['minute'] * 60 + (int) $ptime['second'] 500 : (int) $ptime; 501 502 503 $pubdate = 0; 504 $pubdatestr = (string)$episode->pubDate; 505 if ($pubdatestr) { 506 $pubdate = strtotime($pubdatestr); 507 } 508 if ($pubdate < 1) { 509 debug_event(self::class, 'Invalid episode publication date, skipped', 3); 510 511 return false; 512 } 513 if (!$source) { 514 debug_event(self::class, 'Episode source URL not found, skipped', 3); 515 516 return false; 517 } 518 if (self::get_id_from_source($source) > 0) { 519 debug_event(self::class, 'Episode source URL already exists, skipped', 3); 520 521 return false; 522 } 523 524 if ($pubdate > $afterdate) { 525 $sql = "INSERT INTO `podcast_episode` (`title`, `guid`, `podcast`, `state`, `source`, `website`, `description`, `author`, `category`, `time`, `pubdate`, `addition_time`, `catalog`) VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)"; 526 527 return Dba::write($sql, array( 528 $title, 529 $guid, 530 $this->id, 531 $source, 532 $website, 533 $description, 534 $author, 535 $category, 536 $time, 537 $pubdate, 538 time(), 539 $this->catalog 540 )); 541 } else { 542 debug_event(self::class, 'Episode published before ' . $afterdate . ' (' . $pubdate . '), skipped', 4); 543 544 return true; 545 } 546 } 547 548 /** 549 * update_lastsync 550 * @param integer $time 551 * @return PDOStatement|boolean 552 */ 553 private function update_lastsync($time) 554 { 555 $sql = "UPDATE `podcast` SET `lastsync` = ? WHERE `id` = ?"; 556 557 return Dba::write($sql, array($time, $this->id)); 558 } 559 560 /** 561 * sync_episodes 562 * @param boolean $gather 563 * @return PDOStatement|boolean 564 */ 565 public function sync_episodes($gather = false) 566 { 567 debug_event(self::class, 'Syncing feed ' . $this->feed . ' ...', 4); 568 569 $xmlstr = file_get_contents($this->feed, false, stream_context_create(Core::requests_options())); 570 if ($xmlstr === false) { 571 debug_event(self::class, 'Cannot access feed ' . $this->feed, 1); 572 573 return false; 574 } 575 $xml = simplexml_load_string($xmlstr); 576 if ($xml === false) { 577 debug_event(self::class, 'Cannot read feed ' . $this->feed, 1); 578 579 return false; 580 } 581 582 $this->add_episodes($xml->channel->item, $this->lastsync, $gather); 583 584 return true; 585 } 586 587 /** 588 * remove 589 * @return PDOStatement|boolean 590 */ 591 public function remove() 592 { 593 $episodes = $this->get_episodes(); 594 foreach ($episodes as $episode_id) { 595 $episode = new Podcast_Episode($episode_id); 596 $episode->remove(); 597 } 598 599 $sql = "DELETE FROM `podcast` WHERE `id` = ?"; 600 601 return Dba::write($sql, array($this->id)); 602 } 603 604 /** 605 * get_id_from_source 606 * 607 * Get episode id from the source url. 608 * 609 * @param string $url 610 * @return integer 611 */ 612 public static function get_id_from_source($url) 613 { 614 $sql = "SELECT `id` FROM `podcast_episode` WHERE `source` = ?"; 615 $db_results = Dba::read($sql, array($url)); 616 617 if ($results = Dba::fetch_assoc($db_results)) { 618 return (int)$results['id']; 619 } 620 621 return 0; 622 } 623 624 /** 625 * get_root_path 626 * @return string 627 */ 628 public function get_root_path() 629 { 630 $catalog = Catalog::create_from_id($this->catalog); 631 if (!$catalog->get_type() == 'local') { 632 debug_event(self::class, 'Bad catalog type.', 1); 633 634 return ''; 635 } 636 637 $dirname = $this->title; 638 639 // create path if it doesn't exist 640 if (!is_dir($catalog->path . DIRECTORY_SEPARATOR . $dirname)) { 641 static::create_catalog_path($catalog->path . DIRECTORY_SEPARATOR . $dirname); 642 } 643 644 return $catalog->path . DIRECTORY_SEPARATOR . $dirname; 645 } 646 647 /** 648 * create_catalog_path 649 * This returns the catalog types that are available 650 * @param string $path 651 * @return boolean 652 */ 653 private static function create_catalog_path($path) 654 { 655 if (!is_dir($path)) { 656 if (mkdir($path) === false) { 657 debug_event(__CLASS__, 'Cannot create directory ' . $path, 2); 658 659 return false; 660 } 661 } 662 663 return true; 664 } 665} 666