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\Playback\Stream;
27use Ampache\Module\Playback\Stream_Url;
28use Ampache\Module\Statistics\Stats;
29use Ampache\Module\System\Dba;
30use Ampache\Module\Util\Ui;
31use Ampache\Module\Util\UtilityFactoryInterface;
32use Ampache\Module\Util\VaInfo;
33use Ampache\Module\Authorization\Access;
34use Ampache\Config\AmpConfig;
35use Ampache\Module\System\Core;
36use PDOStatement;
37
38class Podcast_Episode extends database_object implements Media, library_item, GarbageCollectibleInterface
39{
40    protected const DB_TABLENAME = 'podcast_episode';
41
42    public $id;
43    public $title;
44    public $guid;
45    public $podcast;
46    public $state;
47    public $file;
48    public $source;
49    public $size;
50    public $time;
51    public $played;
52    public $type;
53    public $mime;
54    public $website;
55    public $description;
56    public $author;
57    public $category;
58    public $pubdate;
59    public $enabled;
60    public $object_cnt;
61    public $catalog;
62    public $f_title;
63    public $f_file;
64    public $f_size;
65    public $f_time;
66    public $f_time_h;
67    public $f_description;
68    public $f_author;
69    public $f_artist_full;
70    public $f_category;
71    public $f_website;
72    public $f_pubdate;
73    public $f_state;
74    public $link;
75    public $f_link;
76    public $f_podcast;
77    public $f_podcast_link;
78    private $total_count;
79
80    /**
81     * Constructor
82     *
83     * Podcast Episode class
84     * @param integer $episode_id
85     */
86    public function __construct($episode_id = null)
87    {
88        if ($episode_id === null) {
89            return false;
90        }
91
92        $this->id = (int)$episode_id;
93
94        if ($info = $this->get_info($this->id)) {
95            foreach ($info as $key => $value) {
96                $this->$key = $value;
97            }
98            if (!empty($this->file)) {
99                $data          = pathinfo($this->file);
100                $this->type    = strtolower((string)$data['extension']);
101                $this->mime    = Song::type_to_mime($this->type);
102                $this->enabled = true;
103            }
104        } else {
105            $this->id = null;
106
107            return false;
108        }
109
110        return true;
111    } // constructor
112
113    public function getId(): int
114    {
115        return (int) $this->id;
116    }
117
118    /**
119     * garbage_collection
120     *
121     * Cleans up the podcast_episode table
122     */
123    public static function garbage_collection()
124    {
125        Dba::write("DELETE FROM `podcast_episode` USING `podcast_episode` LEFT JOIN `podcast` ON `podcast`.`id` = `podcast_episode`.`podcast` WHERE `podcast`.`id` IS NULL;");
126    }
127
128    /**
129     * get_catalogs
130     *
131     * Get all catalog ids related to this item.
132     * @return integer[]
133     */
134    public function get_catalogs()
135    {
136        return array($this->catalog);
137    }
138
139    /**
140     * format
141     * this function takes the object and formats some values
142     * @param boolean $details
143     * @return boolean
144     */
145    public function format($details = true)
146    {
147        $this->f_title       = scrub_out($this->title);
148        $this->f_description = scrub_out($this->description);
149        $this->f_category    = scrub_out($this->category);
150        $this->f_author      = scrub_out($this->author);
151        $this->f_artist_full = $this->f_author;
152        $this->f_website     = scrub_out($this->website);
153        $this->f_pubdate     = date("c", (int)$this->pubdate);
154        $this->f_state       = ucfirst($this->state);
155
156        // Format the Time
157        $min            = floor($this->time / 60);
158        $sec            = sprintf("%02d", ($this->time % 60));
159        $this->f_time   = $min . ":" . $sec;
160        $hour           = sprintf("%02d", floor($min / 60));
161        $min_h          = sprintf("%02d", ($min % 60));
162        $this->f_time_h = $hour . ":" . $min_h . ":" . $sec;
163        // Format the Size
164        $this->f_size = Ui::format_bytes($this->size);
165        $this->f_file = $this->f_title . '.' . $this->type;
166
167        $this->link   = AmpConfig::get('web_path') . '/podcast_episode.php?action=show&podcast_episode=' . $this->id;
168        $this->f_link = '<a href="' . $this->link . '" title="' . scrub_out($this->f_title) . '">' . scrub_out($this->f_title) . '</a>';
169
170        if ($details) {
171            $podcast = new Podcast($this->podcast);
172            $podcast->format();
173            $this->f_podcast      = $podcast->f_title;
174            $this->f_podcast_link = $podcast->f_link;
175            $this->f_file         = $this->f_podcast . ' - ' . $this->f_file;
176        }
177        if (AmpConfig::get('show_played_times')) {
178            $this->object_cnt = (int) $this->total_count;
179        }
180
181        return true;
182    }
183
184    /**
185     * @return array|mixed
186     */
187    public function get_keywords()
188    {
189        $keywords            = array();
190        $keywords['podcast'] = array(
191            'important' => true,
192            'label' => T_('Podcast'),
193            'value' => $this->f_podcast
194        );
195        $keywords['title'] = array(
196            'important' => true,
197            'label' => T_('Title'),
198            'value' => $this->f_title
199        );
200
201        return $keywords;
202    }
203
204    /**
205     * @return string
206     */
207    public function get_fullname()
208    {
209        return $this->f_title;
210    }
211
212    /**
213     * @return array
214     */
215    public function get_parent()
216    {
217        return array('object_type' => 'podcast', 'object_id' => $this->podcast);
218    }
219
220    /**
221     * @return array
222     */
223    public function get_childrens()
224    {
225        return array();
226    }
227
228    /**
229     * @param string $name
230     * @return array
231     */
232    public function search_childrens($name)
233    {
234        debug_event(self::class, 'search_childrens ' . $name, 5);
235
236        return array();
237    }
238
239    /**
240     * @param string $filter_type
241     * @return array
242     */
243    public function get_medias($filter_type = null)
244    {
245        $medias = array();
246        if ($filter_type === null || $filter_type == 'podcast_episode') {
247            $medias[] = array(
248                'object_type' => 'podcast_episode',
249                'object_id' => $this->id
250            );
251        }
252
253        return $medias;
254    }
255
256    /**
257     * @return mixed|null
258     */
259    public function get_user_owner()
260    {
261        return null;
262    }
263
264    /**
265     * @return string
266     */
267    public function get_default_art_kind()
268    {
269        return 'default';
270    }
271
272    /**
273     * @return string
274     */
275    public function get_description()
276    {
277        return $this->f_description;
278    }
279
280    /**
281     * display_art
282     * @param integer $thumb
283     * @param boolean $force
284     */
285    public function display_art($thumb = 2, $force = false)
286    {
287        $episode_id = null;
288        $type       = null;
289
290        if (Art::has_db($this->id, 'podcast_episode')) {
291            $episode_id = $this->id;
292            $type       = 'podcast_episode';
293        } else {
294            if (Art::has_db($this->podcast, 'podcast') || $force) {
295                $episode_id = $this->podcast;
296                $type       = 'podcast';
297            }
298        }
299
300        if ($episode_id !== null && $type !== null) {
301            Art::display($type, $episode_id, $this->get_fullname(), $thumb, $this->link);
302        }
303    }
304
305    /**
306     * update
307     * This takes a key'd array of data and updates the current podcast episode
308     * @param array $data
309     * @return integer
310     */
311    public function update(array $data)
312    {
313        $title       = isset($data['title']) ? $data['title'] : $this->title;
314        $website     = isset($data['website']) ? $data['website'] : $this->website;
315        $description = isset($data['description']) ? $data['description'] : $this->description;
316        $author      = isset($data['author']) ? $data['author'] : $this->author;
317        $category    = isset($data['category']) ? $data['category'] : $this->category;
318
319        $sql = 'UPDATE `podcast_episode` SET `title` = ?, `website` = ?, `description` = ?, `author` = ?, `category` = ? WHERE `id` = ?';
320        Dba::write($sql, array($title, $website, $description, $author, $category, $this->id));
321
322        $this->title       = $title;
323        $this->website     = $website;
324        $this->description = $description;
325        $this->author      = $author;
326        $this->category    = $category;
327
328        return $this->id;
329    }
330
331    /**
332     * set_played
333     * this checks to see if the current object has been played
334     * if not then it sets it to played. In any case it updates stats.
335     * @param integer $user
336     * @param string $agent
337     * @param array $location
338     * @param integer $date
339     * @return boolean
340     */
341    public function set_played($user, $agent, $location, $date = null)
342    {
343        // ignore duplicates or skip the last track
344        if (!$this->check_play_history($user, $agent, $date)) {
345            return false;
346        }
347        Stats::insert('podcast_episode', $this->id, $user, $agent, $location, 'stream', $date);
348
349        if (!$this->played) {
350            self::update_played(true, $this->id);
351        }
352
353        return true;
354    } // set_played
355
356    /**
357     * @param integer $user
358     * @param string $agent
359     * @param integer $date
360     * @return boolean
361     */
362    public function check_play_history($user, $agent, $date)
363    {
364        return Stats::has_played_history('podcast_episode', $this, $user, $agent, $date);
365    }
366
367    /**
368     * update_played
369     * sets the played flag
370     * @param boolean $new_played
371     * @param integer $id
372     */
373    public static function update_played($new_played, $id)
374    {
375        self::_update_item('played', ($new_played ? 1 : 0), $id, '25');
376    } // update_played
377
378    /**
379     * _update_item
380     * This is a private function that should only be called from within the podcast episode class.
381     * It takes a field, value song_id and level. first and foremost it checks the level
382     * against Core::get_global('user') to make sure they are allowed to update this record
383     * it then updates it and sets $this->{$field} to the new value
384     * @param string $field
385     * @param integer $value
386     * @param integer $song_id
387     * @param integer $level
388     * @return boolean
389     */
390    private static function _update_item($field, $value, $song_id, $level)
391    {
392        /* Check them Rights! */
393        if (!Access::check('interface', $level)) {
394            return false;
395        }
396
397        /* Can't update to blank */
398        if (!strlen(trim((string)$value))) {
399            return false;
400        }
401
402        $sql = "UPDATE `podcast_episode` SET `$field` = ? WHERE `id` = ?";
403        Dba::write($sql, array($value, $song_id));
404
405        return true;
406    } // _update_item
407
408    /**
409     * Get stream name.
410     * @return string
411     */
412    public function get_stream_name()
413    {
414        return $this->f_podcast . " - " . $this->f_title;
415    }
416
417    /**
418     * Get transcode settings.
419     * @param string $target
420     * @param string $player
421     * @param array $options
422     * @return array
423     */
424    public function get_transcode_settings($target = null, $player = null, $options = array())
425    {
426        return Song::get_transcode_settings_for_media($this->type, $target, $player, 'song', $options);
427    }
428
429    /**
430     * play_url
431     * This function takes all the song information and correctly formats a
432     * a stream URL taking into account the downsmapling mojo and everything
433     * else, this is the true function
434     * @param string $additional_params
435     * @param string $player
436     * @param boolean $local
437     * @param int|string $uid
438     * @return string
439     */
440    public function play_url($additional_params = '', $player = '', $local = false, $uid = false)
441    {
442        if (!$this->id) {
443            return '';
444        }
445        if (!$uid) {
446            // No user in the case of upnp. Set to 0 instead. required to fix database insertion errors
447            $uid = Core::get_global('user')->id ?: 0;
448        }
449        // set no use when using auth
450        if (!AmpConfig::get('use_auth') && !AmpConfig::get('require_session')) {
451            $uid = -1;
452        }
453
454        $type = $this->type;
455
456        $this->format();
457        $media_name = $this->get_stream_name() . "." . $type;
458        $media_name = preg_replace("/[^a-zA-Z0-9\. ]+/", "-", $media_name);
459        $media_name = rawurlencode($media_name);
460
461        $url = Stream::get_base_url($local) . "type=podcast_episode&oid=" . $this->id . "&uid=" . (string) $uid . '&format=raw' . $additional_params;
462        if ($player !== '') {
463            $url .= "&player=" . $player;
464        }
465        $url .= "&name=" . $media_name;
466
467        return Stream_Url::format($url);
468    }
469
470    /**
471     * Get stream types.
472     * @param string $player
473     * @return array
474     */
475    public function get_stream_types($player = null)
476    {
477        return Song::get_stream_types_for_type($this->type, $player);
478    }
479
480    /**
481     * remove
482     * @return PDOStatement|boolean
483     */
484    public function remove()
485    {
486        debug_event(self::class, 'Removing podcast episode ' . $this->id, 5);
487
488        if (AmpConfig::get('delete_from_disk') && !empty($this->file)) {
489            if (!unlink($this->file)) {
490                debug_event(self::class, 'Cannot delete file ' . $this->file, 3);
491            }
492        }
493
494        // keep details about deletions
495        $params = array($this->id);
496        $sql    = "REPLACE INTO `deleted_podcast_episode` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`, `podcast`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip`, `podcast` FROM `podcast_episode` WHERE `id` = ?;";
497        Dba::write($sql, $params);
498
499        $sql = "DELETE FROM `podcast_episode` WHERE `id` = ?";
500
501        return Dba::write($sql, $params);
502    }
503
504    /**
505     * change_state
506     * @param string $state
507     * @return PDOStatement|boolean
508     */
509    public function change_state($state)
510    {
511        $sql = "UPDATE `podcast_episode` SET `state` = ? WHERE `id` = ?";
512
513        return Dba::write($sql, array($state, $this->id));
514    }
515
516    /**
517     * gather
518     * download the podcast episode to your catalog
519     */
520    public function gather()
521    {
522        if (!empty($this->source)) {
523            $podcast = new Podcast($this->podcast);
524            $file    = $podcast->get_root_path();
525            if (!empty($file)) {
526                $pinfo = pathinfo($this->source);
527
528                $file .= DIRECTORY_SEPARATOR . $this->pubdate . '-' . str_replace(array('?', '<', '>', '\\', '/'), '_', $this->title) . '-' . strtok($pinfo['basename'], '?');
529                debug_event(self::class, 'Downloading ' . $this->source . ' to ' . $file . ' ...', 4);
530                if (file_put_contents($file, fopen($this->source, 'r')) !== false) {
531                    debug_event(self::class, 'Download completed.', 4);
532                    $this->file = $file;
533
534                    $vainfo = $this->getUtilityFactory()->createVaInfo($this->file);
535                    $vainfo->get_info();
536                    $key   = VaInfo::get_tag_type($vainfo->tags);
537                    $infos = VaInfo::clean_tag_info($vainfo->tags, $key, $file);
538                    // No time information, get it from file
539                    if ($this->time < 1) {
540                        $this->time = $infos['time'];
541                    }
542                    $this->size = $infos['size'];
543
544                    $sql = "UPDATE `podcast_episode` SET `file` = ?, `size` = ?, `time` = ?, `state` = 'completed' WHERE `id` = ?";
545                    Dba::write($sql, array($this->file, $this->size, $this->time, $this->id));
546                } else {
547                    debug_event(self::class, 'Error when downloading podcast episode.', 1);
548                }
549            }
550        } else {
551            debug_event(self::class, 'Cannot download podcast episode ' . $this->id . ', empty source.', 3);
552        }
553    }
554
555    /**
556     * get_deleted
557     * get items from the deleted_podcast_episodes table
558     * @return int[]
559     */
560    public static function get_deleted()
561    {
562        $deleted    = array();
563        $sql        = "SELECT * FROM `deleted_podcast_episode`";
564        $db_results = Dba::read($sql);
565        while ($row = Dba::fetch_assoc($db_results)) {
566            $deleted[] = $row;
567        }
568
569        return $deleted;
570    } // get_deleted
571
572    /**
573     * type_to_mime
574     *
575     * Returns the mime type for the specified file extension/type
576     * @param string $type
577     * @return string
578     */
579    public static function type_to_mime($type)
580    {
581        return Song::type_to_mime($type);
582    }
583
584    /**
585     * @deprecated Inject by constructor
586     */
587    private function getUtilityFactory(): UtilityFactoryInterface
588    {
589        global $dic;
590
591        return $dic->get(UtilityFactoryInterface::class);
592    }
593}
594