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