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\Repository\Model;
26
27use Ampache\Module\Playback\Stream;
28use Ampache\Module\Playback\Stream_Url;
29use Ampache\Module\Statistics\Stats;
30use Ampache\Module\System\Dba;
31use Ampache\Module\Util\ObjectTypeToClassNameMapper;
32use Ampache\Config\AmpConfig;
33use Ampache\Module\System\Core;
34use PDOStatement;
35
36/**
37 * This class handles democratic play, which is a fancy
38 * name for voting based playback.
39 */
40class Democratic extends Tmp_Playlist
41{
42    protected const DB_TABLENAME = 'democratic';
43
44    public $name;
45    public $cooldown;
46    public $level;
47    public $user;
48    public $primary;
49    public $base_playlist;
50
51    public $f_cooldown;
52    public $f_primary;
53    public $f_level;
54
55    // Build local, buy local
56    public $tmp_playlist;
57    public $object_ids = array();
58    public $vote_ids   = array();
59    public $user_votes = array();
60
61    /**
62     * constructor
63     * We need a constructor for this class. It does it's own thing now
64     * @param $democratic_id
65     */
66    public function __construct($democratic_id)
67    {
68        parent::__construct($democratic_id);
69
70        $info = $this->get_info($democratic_id);
71
72        foreach ($info as $key => $value) {
73            $this->$key = $value;
74        }
75    } // constructor
76
77    public function getId(): int
78    {
79        return (int) $this->id;
80    }
81
82    /**
83     * build_vote_cache
84     * This builds a vote cache of the objects we've got in the playlist
85     * @param $ids
86     * @return boolean
87     */
88    public static function build_vote_cache($ids)
89    {
90        if (!is_array($ids) || !count($ids)) {
91            return false;
92        }
93
94        $idlist = '(' . implode(',', $ids) . ')';
95        $sql    = "SELECT `object_id`, COUNT(`user`) AS `count` FROM `user_vote` WHERE `object_id` IN $idlist GROUP BY `object_id`";
96
97        $db_results = Dba::read($sql);
98
99        while ($row = Dba::fetch_assoc($db_results)) {
100            parent::add_to_cache('democratic_vote', $row['object_id'], array($row['count']));
101        }
102
103        return true;
104    } // build_vote_cache
105
106    /**
107     * is_enabled
108     * This function just returns true / false if the current democratic
109     * playlist is currently enabled / configured
110     */
111    public function is_enabled()
112    {
113        if ($this->tmp_playlist) {
114            return true;
115        }
116
117        return false;
118    } // is_enabled
119
120    /**
121     * set_parent
122     * This returns the Tmp_Playlist for this democratic play instance
123     */
124    public function set_parent()
125    {
126        $demo_id = Dba::escape($this->id);
127
128        $sql        = "SELECT * FROM `tmp_playlist` WHERE `session`='$demo_id'";
129        $db_results = Dba::read($sql);
130
131        $row = Dba::fetch_assoc($db_results);
132
133        $this->tmp_playlist = $row['id'];
134    } // set_parent
135
136    /**
137     * set_user_preferences
138     * This sets up a (or all) user(s) to use democratic play. This sets
139     * their play method and playlist method (clear on send) If no user is
140     * passed it does it for everyone and also locks down the ability to
141     * change to admins only
142     *
143     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
144     */
145    public static function set_user_preferences()
146    {
147        // FIXME: Code in single user stuff
148        $preference_id = Preference::id_from_name('play_type');
149        Preference::update_level($preference_id, '75');
150        Preference::update_all($preference_id, 'democratic');
151
152        $allow_demo = Preference::id_from_name('allow_democratic_playback');
153        Preference::update_all($allow_demo, '1');
154
155        $play_method = Preference::id_from_name('playlist_method');
156        Preference::update_all($play_method, 'clear');
157
158        return true;
159    } // set_user_preferences
160
161    /**
162     * format
163     * This makes the variables pretty so that they can be displayed
164     */
165    public function format()
166    {
167        $this->f_cooldown = $this->cooldown . ' ' . T_('minutes');
168        $this->f_primary  = $this->primary ? T_('Primary') : '';
169        $this->f_level    = User::access_level_to_name($this->level);
170    } // format
171
172    /**
173     * get_playlists
174     * This returns all of the current valid 'Democratic' Playlists that have been created.
175     */
176    public static function get_playlists()
177    {
178        $sql = "SELECT `id` FROM `democratic` ORDER BY `name`";
179
180        $db_results = Dba::read($sql);
181        $results    = array();
182        while ($row = Dba::fetch_assoc($db_results)) {
183            $results[] = (int)$row['id'];
184        }
185
186        return $results;
187    } // get_playlists
188
189    /**
190     * get_current_playlist
191     * This returns the current users current playlist, or if specified
192     * this current playlist of the user
193     */
194    public static function get_current_playlist()
195    {
196        $democratic_id = AmpConfig::get('democratic_id');
197
198        if (!$democratic_id) {
199            $level         = Dba::escape(Core::get_global('user')->access);
200            $sql           = "SELECT `id` FROM `democratic` WHERE `level` <= '$level' ORDER BY `level` DESC,`primary` DESC";
201            $db_results    = Dba::read($sql);
202            $row           = Dba::fetch_assoc($db_results);
203            $democratic_id = $row['id'];
204        }
205
206        return new Democratic($democratic_id);
207    } // get_current_playlist
208
209    /**
210     * get_items
211     * This returns a sorted array of all object_ids in this Tmp_Playlist.
212     * The array is multidimensional; the inner array needs to contain the
213     * keys 'id', 'object_type' and 'object_id'.
214     *
215     * Sorting is highest to lowest vote count, then by oldest to newest
216     * vote activity.
217     * @param integer $limit
218     * @return array
219     */
220    public function get_items($limit = null)
221    {
222        // Remove 'unconnected' users votes
223        if (AmpConfig::get('demo_clear_sessions')) {
224            $sql = 'DELETE FROM `user_vote` WHERE `user_vote`.`sid` NOT IN (SELECT `session`.`id` FROM `session`)';
225            Dba::write($sql);
226        }
227
228        $sql = "SELECT `tmp_playlist_data`.`object_type`, `tmp_playlist_data`.`object_id`, `tmp_playlist_data`.`id` FROM `tmp_playlist_data` INNER JOIN `user_vote` ON `user_vote`.`object_id` = `tmp_playlist_data`.`id` WHERE `tmp_playlist_data`.`tmp_playlist` = '" . Dba::escape($this->tmp_playlist) . "' GROUP BY 1, 2, 3 ORDER BY COUNT(*) DESC, MAX(`user_vote`.`date`), MAX(`tmp_playlist_data`.`id`) ";
229
230        if ($limit !== null) {
231            $sql .= 'LIMIT ' . (string)($limit);
232        }
233
234        $db_results = Dba::read($sql);
235        $results    = array();
236        while ($row = Dba::fetch_assoc($db_results)) {
237            if ($row['id']) {
238                $results[] = $row;
239            }
240        }
241
242        return $results;
243    } // get_items
244
245    /**
246     * play_url
247     * This returns the special play URL for democratic play, only open to ADMINs
248     */
249    public function play_url()
250    {
251        $link = Stream::get_base_url() . 'uid=' . scrub_out(Core::get_global('user')->id) . '&demo_id=' . scrub_out($this->id);
252
253        return Stream_Url::format($link);
254    } // play_url
255
256    /**
257     * get_next_object
258     * This returns the next object in the tmp_playlist.
259     * Most of the time this will just be the top entry, but if there is a
260     * base_playlist and no items in the playlist then it returns a random
261     * entry from the base_playlist
262     * @param integer $offset
263     * @return integer|null
264     */
265    public function get_next_object($offset = 0)
266    {
267        // FIXME: Shouldn't this return object_type?
268
269        $offset = (int)($offset);
270
271        $items = $this->get_items($offset + 1);
272
273        if (count($items) > $offset) {
274            return $items[$offset]['object_id'];
275        }
276
277        // If nothing was found and this is a voting playlist then get
278        // from base_playlist
279        if ($this->base_playlist) {
280            $base_playlist = new Playlist($this->base_playlist);
281            $data          = $base_playlist->get_random_items(1);
282
283            return $data[0]['object_id'];
284        } else {
285            $sql        = "SELECT `id` FROM `song` WHERE `enabled`='1' ORDER BY RAND() LIMIT 1";
286            $db_results = Dba::read($sql);
287            $results    = Dba::fetch_assoc($db_results);
288
289            return $results['id'];
290        }
291    } // get_next_object
292
293    /**
294     * get_uid_from_object_id
295     * This takes an object_id and an object type and returns the ID for the row
296     * @param integer $object_id
297     * @param string $object_type
298     * @return mixed
299     */
300    public function get_uid_from_object_id($object_id, $object_type = 'song')
301    {
302        $object_id   = Dba::escape($object_id);
303        $object_type = Dba::escape($object_type);
304        $tmp_id      = Dba::escape($this->tmp_playlist);
305
306        $sql        = "SELECT `id` FROM `tmp_playlist_data` WHERE `object_type`='$object_type' AND `tmp_playlist`='$tmp_id' AND `object_id`='$object_id'";
307        $db_results = Dba::read($sql);
308
309        $row = Dba::fetch_assoc($db_results);
310
311        return $row['id'];
312    } // get_uid_from_object_id
313
314    /**
315     * get_cool_songs
316     * This returns all of the song_ids for songs that have happened within
317     * the last 'cooldown' for this user.
318     */
319    public function get_cool_songs()
320    {
321        // Convert cooldown time to a timestamp in the past
322        $cool_time = time() - ($this->cooldown * 60);
323
324        return Stats::get_object_history(Core::get_global('user')->id, $cool_time);
325    } // get_cool_songs
326
327    /**
328     * vote
329     * This function is called by users to vote on a system wide playlist
330     * This adds the specified objects to the tmp_playlist and adds a 'vote'
331     * by this user, naturally it checks to make sure that the user hasn't
332     * already voted on any of these objects
333     * @param $items
334     */
335    public function add_vote($items)
336    {
337        /* Iterate through the objects if no vote, add to playlist and vote */
338        foreach ($items as $element) {
339            $type      = array_shift($element);
340            $object_id = array_shift($element);
341            if (!$this->has_vote($object_id, $type)) {
342                $this->_add_vote($object_id, $type);
343            }
344        } // end foreach
345    } // vote
346
347    /**
348     * has_vote
349     * This checks to see if the current user has already voted on this object
350     * @param integer $object_id
351     * @param string $type
352     * @return boolean
353     */
354    public function has_vote($object_id, $type = 'song')
355    {
356        $params = array($type, $object_id, $this->tmp_playlist);
357
358        /* Query vote table */
359        $sql = "SELECT `tmp_playlist_data`.`object_id` FROM `user_vote` INNER JOIN `tmp_playlist_data` ON `tmp_playlist_data`.`id`=`user_vote`.`object_id` WHERE `tmp_playlist_data`.`object_type` = ? AND `tmp_playlist_data`.`object_id` = ? AND `tmp_playlist_data`.`tmp_playlist` = ? ";
360        if (Core::get_global('user')->id > 0) {
361            $sql .= "AND `user_vote`.`user` = ? ";
362            $params[] = Core::get_global('user')->id;
363        } else {
364            $sql .= "AND `user_vote`.`sid` = ? ";
365            $params[] = session_id();
366        }
367        $db_results = Dba::read($sql, $params);
368
369        /* If we find  row, they've voted!! */
370        if (Dba::num_rows($db_results)) {
371            return true;
372        }
373
374        return false;
375    } // has_vote
376
377    /**
378     * _add_vote
379     * This takes a object id and user and actually inserts the row
380     * @param integer $object_id
381     * @param string $object_type
382     * @return boolean
383     */
384    private function _add_vote($object_id, $object_type = 'song')
385    {
386        if (!$this->tmp_playlist) {
387            return false;
388        }
389
390        $class_name = ObjectTypeToClassNameMapper::map($object_type);
391        $media      = new $class_name($object_id);
392        $track      = isset($media->track) ? (int)($media->track) : null;
393
394        /* If it's on the playlist just vote */
395        $sql        = "SELECT `id` FROM `tmp_playlist_data` WHERE `tmp_playlist_data`.`object_id` = ? AND `tmp_playlist_data`.`tmp_playlist` = ?";
396        $db_results = Dba::write($sql, array($object_id, $this->tmp_playlist));
397
398        /* If it's not there, add it and pull ID */
399        if (!$results = Dba::fetch_assoc($db_results)) {
400            $sql = "INSERT INTO `tmp_playlist_data` (`tmp_playlist`, `object_id`, `object_type`, `track`) VALUES (?, ?, ?, ?)";
401            Dba::write($sql, array($this->tmp_playlist, $object_id, $object_type, $track));
402            $results['id'] = Dba::insert_id();
403        }
404
405        /* Vote! */
406        $time = time();
407        $sql  = "INSERT INTO user_vote (`user`, `object_id`, `date`, `sid`) VALUES (?, ?, ?, ?)";
408        Dba::write($sql, array(Core::get_global('user')->id, $results['id'], $time, session_id()));
409
410        return true;
411    }
412
413    /**
414     * remove_vote
415     * This is called to remove a vote by a user for an object, it uses the object_id
416     * As that's what we'll have most the time, no need to check if they've got an existing
417     * vote for this, just remove anything that is there
418     * @param $row_id
419     * @return boolean
420     */
421    public function remove_vote($row_id)
422    {
423        $sql    = "DELETE FROM `user_vote` WHERE `object_id` = ? ";
424        $params = array($row_id);
425        if (Core::get_global('user')->id > 0) {
426            $sql .= "AND `user` = ?";
427            $params[] = Core::get_global('user')->id;
428        } else {
429            $sql .= "AND `user_vote`.`sid` = ? ";
430            $params[] = session_id();
431        }
432        Dba::write($sql, $params);
433
434        /* Clean up anything that has no votes */
435        self::prune_tracks();
436
437        return true;
438    } // remove_vote
439
440    /**
441     * delete_votes
442     * This removes the votes for the specified object on the current playlist
443     * @param $row_id
444     * @return boolean
445     */
446    public function delete_votes($row_id)
447    {
448        $row_id = Dba::escape($row_id);
449
450        $sql = "DELETE FROM `user_vote` WHERE `object_id`='$row_id'";
451        Dba::write($sql);
452
453        $sql = "DELETE FROM `tmp_playlist_data` WHERE `id`='$row_id'";
454        Dba::write($sql);
455
456        return true;
457    } // delete_votes
458
459    /**
460     * delete_from_oid
461     * This takes an OID and type and removes the object from the democratic playlist
462     * @param integer $object_id
463     * @param string $object_type
464     * @return boolean
465     */
466    public function delete_from_oid($object_id, $object_type)
467    {
468        $row_id = $this->get_uid_from_object_id($object_id, $object_type);
469        if ($row_id) {
470            debug_event(self::class, 'Removing Votes for ' . $object_id . ' of type ' . $object_type, 5);
471            $this->delete_votes($row_id);
472        } else {
473            debug_event(self::class, 'Unable to find Votes for ' . $object_id . ' of type ' . $object_type, 3);
474        }
475
476        return true;
477    } // delete_from_oid
478
479    /**
480     * delete
481     * This deletes a democratic playlist
482     * @param integer $democratic_id
483     * @return boolean
484     */
485    public static function delete($democratic_id)
486    {
487        $democratic_id = Dba::escape($democratic_id);
488
489        $sql = "DELETE FROM `democratic` WHERE `id`='$democratic_id'";
490        Dba::write($sql);
491
492        $sql = "DELETE FROM `tmp_playlist` WHERE `session`='$democratic_id'";
493        Dba::write($sql);
494
495        self::prune_tracks();
496
497        return true;
498    } // delete
499
500    /**
501     * update
502     * This updates an existing democratic playlist item. It takes a key'd array just like the create
503     * @param array $data
504     * @return boolean
505     */
506    public function update(array $data)
507    {
508        $name    = Dba::escape($data['name']);
509        $base    = (int)Dba::escape($data['democratic']);
510        $cool    = (int)Dba::escape($data['cooldown']);
511        $level   = (int)Dba::escape($data['level']);
512        $default = (int)Dba::escape($data['make_default']);
513        $demo_id = (int)Dba::escape($this->id);
514
515        // no negative ints, this also gives you over 2 million days...
516        if ($cool < 0 || $cool > 3000000000) {
517            return false;
518        }
519
520        $sql = "UPDATE `democratic` SET `name` = ?, `base_playlist` = ?,`cooldown` = ?, `primary` = ?, `level` = ? WHERE `id` = ?";
521        Dba::write($sql, array($name, $base, $cool, $default, $level, $demo_id));
522
523        return true;
524    } // update
525
526    /**
527     * create
528     * This is the democratic play create function it inserts this into the democratic table
529     * @param array $data
530     * @return PDOStatement|boolean
531     */
532    public static function create($data)
533    {
534        // Clean up the input
535        $name    = Dba::escape($data['name']);
536        $base    = (int)Dba::escape($data['democratic']);
537        $cool    = (int)Dba::escape($data['cooldown']);
538        $level   = (int)Dba::escape($data['level']);
539        $default = (int)Dba::escape($data['make_default']);
540        $user    = (int)Dba::escape(Core::get_global('user')->id);
541
542        $sql        = "INSERT INTO `democratic` (`name`, `base_playlist`, `cooldown`, `level`, `user`, `primary`) VALUES ('$name', $base, $cool, $level, $user, $default)";
543        $db_results = Dba::write($sql);
544
545        if ($db_results) {
546            $insert_id = Dba::insert_id();
547            parent::create(array(
548                'session_id' => $insert_id,
549                'type' => 'vote',
550                'object_type' => 'song'
551            ));
552        }
553
554        return $db_results;
555    } // create
556
557    /**
558     * prune_tracks
559     * This replaces the normal prune tracks and correctly removes the votes
560     * as well
561     */
562    public static function prune_tracks()
563    {
564        // This deletes data without votes, if it's a voting democratic playlist
565        $sql = "DELETE FROM `tmp_playlist_data` USING `tmp_playlist_data` LEFT JOIN `user_vote` ON `tmp_playlist_data`.`id`=`user_vote`.`object_id` LEFT JOIN `tmp_playlist` ON `tmp_playlist`.`id`=`tmp_playlist_data`.`tmp_playlist` WHERE `user_vote`.`object_id` IS NULL AND `tmp_playlist`.`type` = 'vote'";
566        Dba::write($sql);
567
568        return true;
569    } // prune_tracks
570
571    /**
572     * clear
573     * This is really just a wrapper function, it clears the entire playlist
574     * including all votes etc.
575     * @return boolean
576     */
577    public function clear()
578    {
579        $tmp_id = Dba::escape($this->tmp_playlist);
580
581        if ((int)$tmp_id > 0) {
582            /* Clear all votes then prune */
583            $sql = "DELETE FROM `user_vote` USING `user_vote` LEFT JOIN `tmp_playlist_data` ON `user_vote`.`object_id` = `tmp_playlist_data`.`id` WHERE `tmp_playlist_data`.`tmp_playlist`='$tmp_id'";
584            Dba::write($sql);
585        }
586
587        // Prune!
588        self::prune_tracks();
589
590        // Clean the votes
591        $this->clear_votes();
592
593        return true;
594    } // clear_playlist
595
596    /**
597     * clean_votes
598     * This removes in left over garbage in the votes table
599     * @return boolean
600     */
601    public function clear_votes()
602    {
603        $sql = "DELETE FROM `user_vote` USING `user_vote` LEFT JOIN `tmp_playlist_data` ON `user_vote`.`object_id`=`tmp_playlist_data`.`id` WHERE `tmp_playlist_data`.`id` IS NULL";
604        Dba::write($sql);
605
606        return true;
607    } // clear_votes
608
609    /**
610     * get_vote
611     * This returns the current count for a specific song
612     * @param integer $id
613     * @return integer
614     */
615    public function get_vote($id)
616    {
617        if (parent::is_cached('democratic_vote', $id)) {
618            return (int)(parent::get_from_cache('democratic_vote', $id))[0];
619        }
620
621        $sql        = "SELECT COUNT(`user`) AS `count` FROM `user_vote` WHERE `object_id` = ?";
622        $db_results = Dba::read($sql, array($id));
623
624        $results = Dba::fetch_assoc($db_results);
625        parent::add_to_cache('democratic_vote', $id, $results);
626
627        return (int)$results['count'];
628    } // get_vote
629}
630