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