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
23/* vim:set softtabstop=4 shiftwidth=4 expandtab: */
24namespace Ampache\Module\Beets;
25
26use Ampache\Repository\Model\Album;
27use Ampache\Module\System\AmpError;
28use Ampache\Repository\Model\Metadata\Repository\Metadata;
29use Ampache\Repository\Model\Metadata\Repository\MetadataField;
30use Ampache\Repository\Model\library_item;
31use Ampache\Repository\Model\Media;
32use Ampache\Module\Util\Ui;
33use Ampache\Module\System\Dba;
34use Ampache\Repository\Model\Song;
35
36/**
37 * Catalog parent for local and remote beets catalog
38 *
39 * @author raziel
40 */
41abstract class Catalog extends \Ampache\Repository\Model\Catalog
42{
43    /**
44     * Added Songs counter
45     * @var integer
46     */
47    protected $addedSongs = 0;
48
49    /**
50     * Verified Songs counter
51     * @var integer
52     */
53    protected $verifiedSongs = 0;
54
55    /**
56     * Array of all songs
57     * @var array
58     */
59    protected $songs = array();
60
61    /**
62     * command which provides the list of all songs
63     * @var string $listCommand
64     */
65    protected $listCommand;
66
67    /**
68     * Counter used for cleaning actions
69     */
70    private int $cleanCounter = 0;
71
72    /**
73     * Constructor
74     *
75     * Catalog class constructor, pulls catalog information
76     * @param integer $catalog_id
77     */
78    public function __construct($catalog_id = null)
79    {
80        // TODO: Basic constructor should be provided from parent
81        if ($catalog_id) {
82            $this->id = (int) $catalog_id;
83            $info     = $this->get_info($catalog_id);
84
85            foreach ($info as $key => $value) {
86                $this->$key = $value;
87            }
88        }
89    }
90
91    /**
92     *
93     * @param Media $media
94     * @return Media
95     */
96    public function prepare_media($media)
97    {
98        debug_event('beets_catalog', 'Play: Started remote stream - ' . $media->file, 5);
99
100        return $media;
101    }
102
103    /**
104     *
105     * @param string $prefix Prefix like add, updated, verify and clean
106     * @param integer $count song count
107     * @param array $song Song array
108     * @param boolean $ignoreTicker ignoring the ticker for the last update
109     */
110    protected function updateUi($prefix, $count, $song = null, $ignoreTicker = false)
111    {
112        if ($ignoreTicker || Ui::check_ticker()) {
113            Ui::update_text($prefix . '_count_' . $this->id, $count);
114            if (isset($song)) {
115                Ui::update_text($prefix . '_dir_' . $this->id, scrub_out($this->getVirtualSongPath($song)));
116            }
117        }
118    }
119
120    /**
121     * Get the parser class like CliHandler or JsonHandler
122     */
123    abstract protected function getParser();
124
125    /**
126     * Adds new songs to the catalog
127     * @param array $options
128     */
129    public function add_to_catalog($options = null)
130    {
131        if (!defined('SSE_OUTPUT')) {
132            require Ui::find_template('show_adds_catalog.inc.php');
133            flush();
134        }
135        set_time_limit(0);
136        if (!defined('SSE_OUTPUT')) {
137            Ui::show_box_top(T_('Running Beets Update'));
138        }
139        $parser = $this->getParser();
140        $parser->setHandler($this, 'addSong');
141        $parser->start($parser->getTimedCommand($this->listCommand, 'added', null));
142        $this->updateUi('add', $this->addedSongs, null, true);
143        $this->update_last_add();
144
145        if (!defined('SSE_OUTPUT')) {
146            Ui::show_box_bottom();
147        }
148    }
149
150    /**
151     * Add $song to ampache if it isn't already
152     * @param array $song
153     */
154    public function addSong($song)
155    {
156        $song['catalog'] = $this->id;
157
158        if ($this->checkSong($song)) {
159            debug_event('beets_catalog', 'Skipping existing song ' . $song['file'], 5);
160        } else {
161            $album_id         = Album::check($song['catalog'], $song['album'], $song['year'], $song['disc'], $song['mbid'], $song['mb_releasegroupid'], $song['album_artist']);
162            $song['album_id'] = $album_id;
163            $songId           = $this->insertSong($song);
164            if (Song::isCustomMetadataEnabled() && $songId) {
165                $songObj = new Song($songId);
166                $this->addMetadata($songObj, $song);
167                $this->updateUi('add', ++$this->addedSongs, $song);
168            }
169        }
170    }
171
172    /**
173     * @param library_item $libraryItem
174     * @param $metadata
175     */
176    public function addMetadata(library_item $libraryItem, $metadata)
177    {
178        $tags = $this->getCleanMetadata($libraryItem, $metadata);
179
180        foreach ($tags as $tag => $value) {
181            $field = $libraryItem->getField($tag);
182            $libraryItem->addMetadata($field, $value);
183        }
184    }
185
186    /**
187     * Get rid of all tags found in the libraryItem
188     * @param library_item $libraryItem
189     * @param array $metadata
190     * @return array
191     */
192    protected function getCleanMetadata(library_item $libraryItem, $metadata)
193    {
194        $tags = array_diff($metadata, get_object_vars($libraryItem));
195        $keys = array_merge(
196            isset($libraryItem::$aliases) ? $libraryItem::$aliases : array(),
197            array_keys(get_object_vars($libraryItem))
198        );
199        foreach ($keys as $key) {
200            unset($tags[$key]);
201        }
202
203        return $tags;
204    }
205
206    /**
207     * Add the song to the DB
208     * @param array $song
209     * @return integer
210     */
211    protected function insertSong($song)
212    {
213        $inserted = Song::insert($song);
214        if ($inserted) {
215            debug_event('beets_catalog', 'Adding song ' . $song['file'], 5, 'ampache-catalog');
216        } else {
217            debug_event('beets_catalog', 'Insert failed for ' . $song['file'], 1);
218            /* HINT: filename (file path) */
219            AmpError::add('general', T_('Unable to add Song - %s'), $song['file']);
220            echo AmpError::display('general');
221        }
222        flush();
223
224        return $inserted;
225    }
226
227    /**
228     * Verify songs.
229     * @return array
230     */
231    public function verify_catalog_proc()
232    {
233        debug_event('beets_catalog', 'Verify: Starting on ' . $this->name, 5);
234        set_time_limit(0);
235
236        /* @var Handler $parser */
237        $parser = $this->getParser();
238        $parser->setHandler($this, 'verifySong');
239        $parser->start($parser->getTimedCommand($this->listCommand, 'mtime', $this->last_update));
240        $this->updateUi('verify', $this->verifiedSongs, null, true);
241        $this->update_last_update();
242
243        return array('updated' => $this->verifiedSongs, 'total' => $this->verifiedSongs);
244    }
245
246    /**
247     * Verify and update a song
248     * @param array $beetsSong
249     */
250    public function verifySong($beetsSong)
251    {
252        $song                  = new Song($this->getIdFromPath($beetsSong['file']));
253        $beetsSong['album_id'] = $song->album;
254
255        if ($song->id) {
256            $song->update($beetsSong);
257            if (Song::isCustomMetadataEnabled()) {
258                $tags = $this->getCleanMetadata($song, $beetsSong);
259                $this->updateMetadata($song, $tags);
260            }
261            $this->updateUi('verify', ++$this->verifiedSongs, $beetsSong);
262        }
263    }
264
265    /**
266     * Cleans the Catalog.
267     * This way is a little fishy, but if we start beets for every single file, it may take horribly long.
268     * So first we get the difference between our and the beets database and then clean up the rest.
269     * @return integer
270     */
271    public function clean_catalog_proc()
272    {
273        $parser      = $this->getParser();
274        $this->songs = $this->getAllSongfiles();
275        $parser->setHandler($this, 'removeFromDeleteList');
276        $parser->start($this->listCommand);
277        $count = count($this->songs);
278        if ($count > 0) {
279            $this->deleteSongs($this->songs);
280        }
281        if (Song::isCustomMetadataEnabled()) {
282            Metadata::garbage_collection();
283            MetadataField::garbage_collection();
284        }
285        $this->updateUi('clean', $this->cleanCounter, null, true);
286
287        return (int)$count;
288    }
289
290    /**
291     * move_catalog_proc
292     * This function updates the file path of the catalog to a new location (unsupported)
293     * @param string $new_path
294     * @return boolean
295     */
296    public function move_catalog_proc($new_path)
297    {
298        return false;
299    }
300
301    /**
302     * @return boolean
303     */
304    public function cache_catalog_proc()
305    {
306        return false;
307    }
308
309    /**
310     * Remove a song from the "to be deleted"-list if it was found.
311     * @param array $song
312     */
313    public function removeFromDeleteList($song)
314    {
315        $key = array_search($song['file'], $this->songs, true);
316        $this->updateUi('clean', ++$this->cleanCounter, $song);
317        if ($key) {
318            unset($this->songs[$key]);
319        }
320    }
321
322    /**
323     * Delete Song from DB
324     * @param array $songs
325     */
326    protected function deleteSongs($songs)
327    {
328        $ids = implode(',', array_keys($songs));
329        $sql = "DELETE FROM `song` WHERE `id` IN ($ids)";
330        Dba::write($sql);
331    }
332
333    /**
334     *
335     * @param string $path
336     * @return integer|boolean
337     */
338    protected function getIdFromPath($path)
339    {
340        $sql        = "SELECT `id` FROM `song` WHERE `file` = ?";
341        $db_results = Dba::read($sql, array($path));
342
343        $row = Dba::fetch_row($db_results);
344
345        return isset($row) ? $row[0] : false;
346    }
347
348    /**
349     * Get all songs from the DB into a array
350     * @return array array(id => file)
351     */
352    public function getAllSongfiles()
353    {
354        $sql        = "SELECT `id`, `file` FROM `song` WHERE `catalog` = ?";
355        $db_results = Dba::read($sql, array($this->id));
356
357        $files = array();
358        while ($row = Dba::fetch_row($db_results)) {
359            $files[$row[0]] = $row[1];
360        }
361
362        return $files;
363    }
364
365    /**
366     * Assembles a virtual Path. Mostly just to looks nice in the UI.
367     * @param array $song
368     * @return string
369     */
370    protected function getVirtualSongPath($song)
371    {
372        return implode('/', array(
373            $song['artist'],
374            $song['album'],
375            $song['title']
376        ));
377    }
378
379    /**
380     * get_description
381     * This returns the description of this catalog
382     */
383    public function get_description()
384    {
385        return $this->description;
386    }
387
388    /**
389     * get_version
390     * This returns the current version
391     */
392    public function get_version()
393    {
394        return $this->version;
395    }
396
397    /**
398     * get_type
399     * This returns the current catalog type
400     */
401    public function get_type()
402    {
403        return $this->type;
404    }
405
406    /**
407     * Doesn't seems like we need this...
408     * @param string $file_path
409     */
410    public function get_rel_path($file_path)
411    {
412    }
413
414    /**
415     * format
416     *
417     * This makes the object human-readable.
418     */
419    public function format()
420    {
421        parent::format();
422    }
423
424    /**
425     * @param $song
426     * @param $tags
427     */
428    public function updateMetadata($song, $tags)
429    {
430        foreach ($tags as $tag => $value) {
431            $field = $song->getField($tag);
432            $song->updateOrInsertMetadata($field, $value);
433        }
434    }
435}
436