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
23declare(strict_types=0);
24
25namespace Ampache\Module\Catalog;
26
27use Ampache\Config\AmpConfig;
28use Ampache\Module\Util\UtilityFactoryInterface;
29use Ampache\Repository\Model\Catalog;
30use Ampache\Repository\Model\Media;
31use Ampache\Repository\Model\Podcast_Episode;
32use Ampache\Repository\Model\Song;
33use Ampache\Repository\Model\Song_Preview;
34use Ampache\Repository\Model\Video;
35use Ampache\Module\System\AmpError;
36use Ampache\Module\System\Core;
37use Ampache\Module\System\Dba;
38use Ampache\Module\Util\Ui;
39use Ampache\Module\Util\VaInfo;
40use Exception;
41use ReflectionException;
42
43/**
44 * This class handles all actual work in regards to remote Seafile catalogs.
45 */
46class Catalog_Seafile extends Catalog
47{
48    private static $version     = '000001';
49    private static $type        = 'seafile';
50    private static $description = 'Seafile Remote Catalog';
51    private static $table_name  = 'catalog_seafile';
52
53    private $seafile;
54
55    /**
56     * get_description
57     * This returns the description of this catalog
58     */
59    public function get_description()
60    {
61        return self::$description;
62    } // get_description
63
64    /**
65     * get_version
66     * This returns the current version
67     */
68    public function get_version()
69    {
70        return self::$version;
71    } // get_version
72
73    /**
74     * get_type
75     * This returns the current catalog type
76     */
77    public function get_type()
78    {
79        return self::$type;
80    } // get_type
81
82    /**
83     * get_create_help
84     * This returns hints on catalog creation
85     */
86    public function get_create_help()
87    {
88        $help = "<ul><li>" . T_("Install a Seafile server as described in the documentation") . "</li><li>" . T_("Enter URL to server (e.g. 'https://seafile.example.com') and library name (e.g. 'Music').") . "</li><li>" . T_("API Call Delay is the delay inserted between repeated requests to Seafile (such as during an Add or Clean action) to accommodate Seafile's Rate Limiting.") . "<br/>" . T_("The default is tuned towards Seafile's default rate limit settings.") . "</li><li>" . T_("After creating the Catalog, you must 'Make it ready' on the Catalog table.") . "</li></ul>";
89
90        return sprintf($help, "<a target='_blank' href='https://www.seafile.com/'>https://www.seafile.com/</a>",
91            "<a href='https://forum.syncwerk.com/t/too-many-requests-when-using-web-api-status-code-429/2330'>",
92            "</a>");
93    } // get_create_help
94
95    /**
96     * is_installed
97     * This returns true or false if remote catalog is installed
98     */
99    public function is_installed()
100    {
101        $sql        = "SHOW TABLES LIKE '" . self::$table_name . "'";
102        $db_results = Dba::query($sql);
103
104        return (Dba::num_rows($db_results) > 0);
105    } // is_installed
106
107    /**
108     * install
109     * This function installs the remote catalog
110     */
111    public function install()
112    {
113        $collation = (AmpConfig::get('database_collation', 'utf8mb4_unicode_ci'));
114        $charset   = (AmpConfig::get('database_charset', 'utf8mb4'));
115        $engine    = ($charset == 'utf8mb4') ? 'InnoDB' : 'MYISAM';
116
117        $sql = "CREATE TABLE `" . self::$table_name . "` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `server_uri` VARCHAR(255) COLLATE $collation NOT NULL, `api_key` VARCHAR(100) COLLATE $collation NOT NULL, `library_name` VARCHAR(255) COLLATE $collation NOT NULL, `api_call_delay` INT NOT NULL, `catalog_id` INT(11) NOT NULL) ENGINE = $engine DEFAULT CHARSET=$charset COLLATE=$collation";
118        Dba::query($sql);
119
120        return true;
121    }
122
123    /**
124     * catalog_fields
125     *
126     * Return the necessary settings fields for creating a new Seafile catalog
127     * @return array
128     */
129    public function catalog_fields()
130    {
131        $fields = array();
132
133        $fields['server_uri'] = array(
134            'description' => T_('Server URI'),
135            'type' => 'text',
136            'value' => 'https://seafile.example.org/'
137        );
138        $fields['library_name']   = array('description' => T_('Library Name'), 'type' => 'text', 'value' => 'Music');
139        $fields['api_call_delay'] = array('description' => T_('API Call Delay'), 'type' => 'number', 'value' => '250');
140        $fields['username']       = array('description' => T_('Seafile Username/Email'), 'type' => 'text', 'value' => '');
141        $fields['password']       = array('description' => T_('Seafile Password'), 'type' => 'password', 'value' => '');
142
143        return $fields;
144    }
145
146    /**
147     * isReady
148     *
149     * Returns whether the catalog is ready for use.
150     */
151    public function isReady()
152    {
153        return $this->seafile->ready();
154    }
155
156    /**
157     * create_type
158     *
159     * This creates a new catalog type entry for a catalog
160     * @param $catalog_id
161     * @param array $data
162     * @return boolean
163     */
164    public static function create_type($catalog_id, $data)
165    {
166        $server_uri     = rtrim(trim($data['server_uri']), '/');
167        $library_name   = trim($data['library_name']);
168        $api_call_delay = trim($data['api_call_delay']);
169        $username       = trim($data['username']);
170        $password       = trim($data['password']);
171
172        if (!strlen($server_uri)) {
173            AmpError::add('general', T_('Seafile server URL is required'));
174
175            return false;
176        }
177
178        if (!strlen($library_name)) {
179            AmpError::add('general', T_('Seafile server library name is required'));
180
181            return false;
182        }
183
184        if (!strlen($username)) {
185            AmpError::add('general', T_('Seafile username is required'));
186
187            return false;
188        }
189
190        if (!strlen($password)) {
191            AmpError::add('general', T_('Seafile password is required'));
192
193            return false;
194        }
195
196        if (!is_numeric($api_call_delay)) {
197            AmpError::add('general', T_('API Call Delay must have a numeric value'));
198
199            return false;
200        }
201
202        try {
203            $api_key = SeafileAdapter::request_api_key($server_uri, $username, $password);
204
205            debug_event('seafile_catalog', 'Retrieved API token for user ' . $username . '.', 1);
206        } catch (Exception $error) {
207            /* HINT: exception error message */
208            AmpError::add('general',
209                sprintf(T_('There was a problem authenticating against the Seafile API: %s'), $error->getMessage()));
210            debug_event('seafile_catalog', 'Exception while Authenticating: ' . $error->getMessage(), 2);
211        }
212
213        if ($api_key == null) {
214            return false;
215        }
216
217        $sql = "INSERT INTO `catalog_seafile` (`server_uri`, `api_key`, `library_name`, `api_call_delay`, `catalog_id`) VALUES (?, ?, ?, ?, ?)";
218        Dba::write($sql, array($server_uri, $api_key, $library_name, (int)($api_call_delay), $catalog_id));
219
220        return true;
221    }
222
223    /**
224     * Constructor
225     *
226     * Catalog class constructor, pulls catalog information
227     * @param integer $catalog_id
228     */
229    public function __construct($catalog_id = null)
230    {
231        if ($catalog_id) {
232            $this->id = (int)$catalog_id;
233            $info     = $this->get_info($catalog_id);
234
235            $this->seafile = new SeafileAdapter($info['server_uri'], $info['library_name'], $info['api_call_delay'],
236                $info['api_key']);
237        }
238    }
239
240    /**
241     * @param string $file_path
242     * @return string
243     */
244    public function get_rel_path($file_path)
245    {
246        $arr = $this->seafile->from_virtual_path($file_path);
247
248        return $arr['path'] . "/" . $arr['filename'];
249    }
250
251    /**
252     * add_to_catalog
253     * this function adds new files to an
254     * existing catalog
255     * @param array $options
256     * @return boolean
257     */
258    public function add_to_catalog($options = null)
259    {
260        // Prevent the script from timing out
261        set_time_limit(0);
262
263        if (!defined('SSE_OUTPUT')) {
264            Ui::show_box_top(T_('Running Seafile Remote Update'));
265        }
266
267        $success = false;
268
269        if ($this->seafile->prepare()) {
270            $count = $this->seafile->for_all_files(function ($file) {
271                if ($file->size == 0) {
272                    debug_event('seafile_catalog', 'read ' . $file->name . " ignored, 0 bytes", 5);
273
274                    return 0;
275                }
276
277                $is_audio_file = Catalog::is_audio_file($file->name);
278                $is_video_file = Catalog::is_video_file($file->name);
279
280                if ($is_audio_file && count($this->get_gather_types('music')) > 0) {
281                    if ($this->insert_song($file)) {
282                        return 1;
283                    }
284                    //} elseif ($is_video_file && count($this->get_gather_types('video')) > 0) {
285                    //    // TODO $this->insert_video()
286                } elseif (!$is_audio_file && !$is_video_file) {
287                    debug_event('seafile_catalog', 'read ' . $file->name . " ignored, unknown media file type", 5);
288                } else {
289                    debug_event('seafile_catalog', 'read ' . $file->name . " ignored, bad media type for this catalog.",
290                        5);
291                }
292
293                return 0;
294            });
295
296            Ui::update_text(T_('Catalog Updated'), /* HINT: count of songs updated */ sprintf(T_('Total Media: [%s]'),
297                $count));
298
299            if ($count < 1) {
300                AmpError::add('general', T_('No media was updated, did you respect the patterns?'));
301            } else {
302                $success = true;
303            }
304        }
305
306        if (!defined('SSE_OUTPUT')) {
307            Ui::show_box_bottom();
308        }
309
310        $this->update_last_add();
311
312        return $success;
313    }
314
315    /**
316     * _insert_local_song
317     *
318     * Insert a song that isn't already in the database.
319     * @param $file
320     * @return boolean|int
321     */
322    private function insert_song($file)
323    {
324        if ($this->check_remote_song($this->seafile->to_virtual_path($file))) {
325            debug_event('seafile_catalog', 'Skipping existing song ' . $file->name, 5);
326            /* HINT: filename (File path) */
327            Ui::update_text('', sprintf(T_('Skipping existing song: %s'), $file->name));
328        } else {
329            debug_event('seafile_catalog', 'Adding song ' . $file->name, 5);
330            try {
331                $results = $this->download_metadata($file);
332                /* HINT: filename (File path) */
333                Ui::update_text('', sprintf(T_('Adding a new song: %s'), $file->name));
334                $added = Song::insert($results);
335
336                if ($added) {
337                    $this->count++;
338                }
339
340                return $added;
341            } catch (Exception $error) {
342                /* HINT: %1 filename (File path), %2 error message */
343                debug_event('seafile_catalog',
344                    sprintf('Could not add song "%1$s": %2$s', $file->name, $error->getMessage()), 1);
345                /* HINT: filename (File path) */
346                Ui::update_text('', sprintf(T_('Could not add song: %s'), $file->name));
347            }
348        }
349
350        return false;
351    }
352
353    /**
354     * @param $file
355     * @param string $sort_pattern
356     * @param string $rename_pattern
357     * @param array $gather_types
358     * @return array
359     * @throws Exception
360     */
361    private function download_metadata($file, $sort_pattern = '', $rename_pattern = '', $gather_types = null)
362    {
363        // Check for patterns
364        if (!$sort_pattern || !$rename_pattern) {
365            $sort_pattern   = $this->sort_pattern;
366            $rename_pattern = $this->rename_pattern;
367        }
368
369        debug_event('seafile_catalog', 'Downloading partial song ' . $file->name, 5);
370
371        $tempfilename = $this->seafile->download($file, true);
372
373        if ($gather_types === null) {
374            $gather_types = $this->get_gather_types('music');
375        }
376
377        $vainfo = $this->getUtilityFactory()->createVaInfo(
378            $tempfilename,
379            $gather_types,
380            '',
381            '',
382            $sort_pattern,
383            $rename_pattern,
384            true
385        );
386        $vainfo->forceSize($file->size);
387        $vainfo->get_info();
388
389        $key = VaInfo::get_tag_type($vainfo->tags);
390
391        // maybe fix stat-ing-nonexistent-file bug?
392        $vainfo->tags['general']['size'] = (int)($file->size);
393
394        $results = VaInfo::clean_tag_info($vainfo->tags, $key, $file->name);
395
396        // Set the remote path
397        $results['catalog'] = $this->id;
398
399        $results['file'] = $this->seafile->to_virtual_path($file);
400
401        return $results;
402    }
403
404    /**
405     * @return array
406     * @throws ReflectionException
407     */
408    public function verify_catalog_proc()
409    {
410        $results = array('total' => 0, 'updated' => 0);
411
412        set_time_limit(0);
413
414        if ($this->seafile->prepare()) {
415            $sql        = 'SELECT `id`, `file`, `title` FROM `song` WHERE `catalog` = ?';
416            $db_results = Dba::read($sql, array($this->id));
417            while ($row = Dba::fetch_assoc($db_results)) {
418                $results['total']++;
419                debug_event('seafile_catalog', 'Verify starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5,
420                    'ampache-catalog');
421                $fileinfo = $this->seafile->from_virtual_path($row['file']);
422
423                $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']);
424
425                $metadata = null;
426
427                if ($file !== null) {
428                    $metadata = $this->download_metadata($file);
429                }
430
431                if ($metadata !== null) {
432                    debug_event('seafile_catalog', 'Verify updating song', 5, 'ampache-catalog');
433                    $song = new Song($row['id']);
434                    $info = ($song->id) ? self::update_song_from_tags($metadata, $song) : array();
435                    if ($info['change']) {
436                        Ui::update_text('', sprintf(T_('Updated song: "%s"'), $row['title']));
437                        $results['updated']++;
438                    } else {
439                        Ui::update_text('', sprintf(T_('Song up to date: "%s"'), $row['title']));
440                    }
441                } else {
442                    debug_event('seafile_catalog', 'Verify removing song', 5, 'ampache-catalog');
443                    Ui::update_text('', sprintf(T_('Removing song: "%s"'), $row['title']));
444                    //$dead++;
445                    Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id']));
446                }
447            }
448
449            $this->update_last_update();
450        }
451
452        return $results;
453    }
454
455    /**
456     * @param Media $media
457     * @param array $gather_types
458     * @param string $sort_pattern
459     * @param string $rename_pattern
460     * @return array|null
461     * @throws Exception
462     */
463    public function get_media_tags($media, $gather_types, $sort_pattern, $rename_pattern)
464    {
465        if ($this->seafile->prepare()) {
466            $fileinfo = $this->seafile->from_virtual_path($media->file);
467
468            $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']);
469
470            if ($file !== null) {
471                return $this->download_metadata($file, $sort_pattern, $rename_pattern, $gather_types);
472            }
473        }
474
475        return null;
476    }
477
478    /**
479     * clean_catalog_proc
480     *
481     * Removes songs that no longer exist.
482     */
483    public function clean_catalog_proc()
484    {
485        $dead = 0;
486
487        set_time_limit(0);
488
489        if ($this->seafile->prepare()) {
490            $sql        = 'SELECT `id`, `file` FROM `song` WHERE `catalog` = ?';
491            $db_results = Dba::read($sql, array($this->id));
492            while ($row = Dba::fetch_assoc($db_results)) {
493                debug_event('seafile_catalog', 'Clean starting work on ' . $row['file'] . '(' . $row['id'] . ')', 5);
494                $file = $this->seafile->from_virtual_path($row['file']);
495
496                try {
497                    $exists = $this->seafile->get_file($file['path'], $file['filename']) !== null;
498                } catch (Exception $error) {
499                    Ui::update_text(T_("There Was a Problem"),
500                        /* HINT: %1 filename (File path), %2 Error Message */ sprintf(T_('There was an error while checking this song "%1$s": %2$s'),
501                            $file['filename'], $error->getMessage()));
502                    debug_event('seafile_catalog', 'Clean Exception: ' . $error->getMessage(), 2);
503
504                    continue;
505                }
506
507                if ($exists) {
508                    debug_event('seafile_catalog', 'Clean keeping song', 5);
509                    /* HINT: filename (File path) */
510                    Ui::update_text('', sprintf(T_('Keeping song: %s'), $file['filename']));
511                } else {
512                    /* HINT: filename (File path) */
513                    Ui::update_text('', sprintf(T_('Removing song: "%s"'), $file['filename']));
514                    debug_event('seafile_catalog', 'Clean removing song', 5);
515                    $dead++;
516                    Dba::write('DELETE FROM `song` WHERE `id` = ?', array($row['id']));
517                }
518            }
519
520            $this->update_last_clean();
521        }
522
523        return $dead;
524    }
525
526    /**
527     * move_catalog_proc
528     * This function updates the file path of the catalog to a new location (unsupported)
529     * @param string $new_path
530     * @return boolean
531     */
532    public function move_catalog_proc($new_path)
533    {
534        return false;
535    }
536
537    /**
538     * @return boolean
539     */
540    public function cache_catalog_proc()
541    {
542        return false;
543    }
544
545    /**
546     * check_remote_song
547     *
548     * checks to see if a remote song exists in the database or not
549     * if it find a song it returns the UID
550     * @param $file
551     * @return boolean|mixed
552     */
553    public function check_remote_song($file)
554    {
555        $sql        = 'SELECT `id` FROM `song` WHERE `file` = ?';
556        $db_results = Dba::read($sql, array($file));
557
558        if ($results = Dba::fetch_assoc($db_results)) {
559            return $results['id'];
560        }
561
562        return false;
563    }
564
565    /**
566     * format
567     *
568     * This makes the object human-readable.
569     */
570    public function format()
571    {
572        parent::format();
573
574        if ($this->seafile != null) {
575            $this->f_info      = $this->seafile->get_format_string();
576            $this->f_full_info = $this->seafile->get_format_string();
577        } else {
578            $this->f_info      = "Seafile Catalog";
579            $this->f_full_info = "Seafile Catalog";
580        }
581    }
582
583    /**
584     * @param Podcast_Episode|Song|Song_Preview|Video $media
585     * @return Media|Podcast_Episode|Song|Song_Preview|Video|null
586     */
587    public function prepare_media($media)
588    {
589        if ($this->seafile->prepare()) {
590            set_time_limit(0);
591
592            $fileinfo = $this->seafile->from_virtual_path($media->file);
593
594            $file = $this->seafile->get_file($fileinfo['path'], $fileinfo['filename']);
595
596            $tempfile = $this->seafile->download($file);
597
598            $media->file   = $tempfile;
599            $media->f_file = $fileinfo['filename'];
600
601            // in case this didn't get set for some reason
602            if ($media->size == 0) {
603                $media->size = Core::get_filesize($tempfile);
604            }
605        }
606
607        return $media;
608    }
609
610    /**
611     * @deprecated Inject by constructor
612     */
613    private function getUtilityFactory(): UtilityFactoryInterface
614    {
615        global $dic;
616
617        return $dic->get(UtilityFactoryInterface::class);
618    }
619}
620