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