ampache/ampache

View on GitHub
src/Repository/Model/User.php

Summary

Maintainability
F
4 days
Test Coverage
<?php

declare(strict_types=0);

/**
 * vim:set softtabstop=4 shiftwidth=4 expandtab:
 *
 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
 * Copyright Ampache.org, 2001-2023
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

namespace Ampache\Repository\Model;

use Ampache\Config\AmpConfig;
use Ampache\Module\Authorization\AccessLevelEnum;
use Ampache\Module\Statistics\Stats;
use Ampache\Module\System\AmpError;
use Ampache\Module\System\Core;
use Ampache\Module\System\Dba;
use Ampache\Module\Util\ObjectTypeToClassNameMapper;
use Ampache\Module\Util\Ui;
use Ampache\Repository\IpHistoryRepositoryInterface;
use Ampache\Repository\UserRepositoryInterface;
use Exception;
use PDOStatement;

/**
 * This class handles all of the user related functions including the creation
 * and deletion of the user objects from the database by default you construct it
 * with a user_id from user.id
 */
class User extends database_object
{
    /** @var int Defines the internal system user-id */
    public const INTERNAL_SYSTEM_USER_ID = -1;

    protected const DB_TABLENAME = 'user';

    // Basic Components
    public int $id = 0;
    public ?string $username;
    public ?string $fullname;
    public ?string $email;
    public ?string $website;
    public ?string $apikey;
    public int $access;
    public bool $disabled;
    public int $last_seen;
    public ?int $create_date;
    public ?string $validation;
    public ?string $state;
    public ?string $city;
    public bool $fullname_public;
    public ?string $rsstoken;
    public ?string $streamtoken;
    public int $catalog_filter_group;

    // Constructed variables
    public string $ip_history = '';
    /**
     * @var array $prefs
     */
    public $prefs = array();
    /**
     * @var Tmp_Playlist|null $playlist
     */
    public $playlist;
    /**
     * @var null|string $f_name
     */
    public $f_name;
    /**
     * @var null|string $f_last_seen
     */
    public $f_last_seen;
    /**
     * @var null|string $f_create_date
     */
    public $f_create_date;
    /**
     * @var null|string $link
     */
    public $link;

    private ?string $f_link = null;
    /**
     * @var null|string $f_usage
     */
    public $f_usage;
    /**
     * @var null|string $f_avatar
     */
    public $f_avatar;
    /**
     * @var null|string $f_avatar_mini
     */
    public $f_avatar_mini;
    /**
     * @var null|string $f_avatar_medium
     */
    public $f_avatar_medium;
    /**
     * @var array $catalogs;
     */
    public $catalogs;

    private ?bool $has_art = null;

    /**
     * Constructor
     * This function is the constructor object for the user
     * class, it currently takes a username
     * @param int|null $user_id
     */
    public function __construct($user_id = 0)
    {
        if (!$user_id) {
            return;
        }

        $info = $this->has_info((int)$user_id);
        if (empty($info)) {
            return;
        }
        foreach ($info as $key => $value) {
            $this->$key = $value;
        }

        // Make sure the Full name is always filled
        if (strlen((string)$this->fullname) < 1) {
            $this->fullname = $this->username;
        }
    }

    public function getId(): int
    {
        return (int)($this->id ?: 0);
    }

    public function isNew(): bool
    {
        return $this->getId() === 0;
    }

    /**
     * has_info
     * This function returns the information for this object
     * @param int $user_id
     * @return array
     */
    private function has_info(int $user_id): array
    {
        if (User::is_cached('user', $user_id)) {
            return User::get_from_cache('user', $user_id);
        }

        // If the ID is -1 then send back generic data
        if ($user_id === -1) {
            return array(
                'id' => -1,
                'username' => 'System',
                'fullname' => 'Ampache User',
                'fullname_public' => 1,
                'email' => null,
                'website' => null,
                'access' => 25,
                'disabled' => 0,
                'catalog_filter_group' => 0,
                'catalogs' => self::get_user_catalogs(-1),
                'apikey' => null,
                'rsstoken' => null,
                'streamtoken' => null,
            );
        }

        $sql        = "SELECT `id`, `username`, `fullname`, `email`, `website`, `apikey`, `access`, `disabled`, `last_seen`, `create_date`, `validation`, `state`, `city`, `fullname_public`, `rsstoken`, `streamtoken`, `catalog_filter_group` FROM `user` WHERE `id` = ?;";
        $db_results = Dba::read($sql, array($user_id));

        $data = Dba::fetch_assoc($db_results);

        User::add_to_cache('user', $user_id, $data);

        return $data;
    }

    /**
     * load_playlist
     * This is called once per page load it makes sure that this session
     * has a tmp_playlist, creating it if it doesn't, then sets $this->playlist
     * as a tmp_playlist object that can be fiddled with later on
     */
    public function load_playlist(): void
    {
        if ($this->playlist === null && session_id()) {
            $this->playlist = Tmp_Playlist::get_from_session(session_id());
        }
    }

    /**
     * get_playlists
     * Get your playlists and just your playlists
     * @param bool $show_all
     */
    public function get_playlists($show_all): array
    {
        $results = array();
        $sql     = ($show_all)
            ? "SELECT `id` FROM `playlist` WHERE `user` = ? ORDER BY `name`;"
            : "SELECT `id` FROM `playlist` WHERE `user` = ? AND `type` = 'public' ORDER BY `name`;";

        $params     = array($this->id);
        $db_results = Dba::read($sql, $params);
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        return $results;
    }

    /**
     * get_from_global
     */
    public static function get_from_global(): ?User
    {
        $globalUser = Core::get_global('user');

        return (!empty($globalUser))
            ? $globalUser
            : null;
    }

    /**
     * get_from_username
     * This returns a built user from a username. This is a
     * static function so it doesn't require an instance
     * @param string $username
     */
    public static function get_from_username($username): ?User
    {
        return static::getUserRepository()->findByUsername($username);
    }

    /**
     * get_user_catalogs
     * This returns the catalogs as an array of ids that this user is allowed to access
     * @param int $user_id
     * @param string $filter
     * @return int[]
     */
    public static function get_user_catalogs($user_id, $filter = ''): array
    {
        if (parent::is_cached('user_catalog' . $filter, $user_id)) {
            return parent::get_from_cache('user_catalog' . $filter, $user_id);
        }

        $catalogs = Catalog::get_catalogs($filter, $user_id);

        parent::add_to_cache('user_catalog' . $filter, $user_id, $catalogs);

        return $catalogs;
    }

    /**
     * get_catalogs
     * This returns the catalogs as an array of ids that this user is allowed to access
     * @return int[]
     */
    public function get_catalogs($filter): array
    {
        if (!isset($this->catalogs[$filter])) {
            $this->catalogs[$filter] = self::get_user_catalogs($this->id, $filter);
        }

        return $this->catalogs[$filter];
    }

    /**
     * get_preferences
     * This is a little more complicate now that we've got many types of preferences
     * This function pulls all of them an arranges them into a spiffy little array
     * You can specify a type to limit it to a single type of preference
     * []['title'] = uppercase type name
     * []['prefs'] = array(array('name', 'display', 'value'));
     * []['admin'] = t/f value if this is an admin only section
     * @param int|string $type
     * @param bool $system
     * @return array
     */
    public function get_preferences($type = 0, $system = false): array
    {
        $user_limit = "";
        if (!$system) {
            $user_id    = $this->id;
            $user_limit = "AND `preference`.`category` != 'system'";
        } else {
            $user_id = -1;
            if ($type != '0') {
                $user_limit = "AND `preference`.`category` = '" . Dba::escape($type) . "'";
            }
        }

        $sql        = "SELECT `preference`.`name`, `preference`.`description`, `preference`.`category`, `preference`.`subcategory`, preference.level, user_preference.value FROM `preference` INNER JOIN `user_preference` ON `user_preference`.`preference` = `preference`.`id` WHERE `user_preference`.`user` = ? " . $user_limit . " ORDER BY `preference`.`category`, `preference`.`subcategory`, `preference`.`description`";
        $db_results = Dba::read($sql, array($user_id));
        $results    = array();
        $type_array = array();
        /* Ok this is crappy, need to clean this up or improve the code FIXME */
        while ($row = Dba::fetch_assoc($db_results)) {
            $type  = $row['category'];
            $admin = false;
            if ($type == 'system') {
                $admin = true;
            }
            $type_array[$type][$row['name']] = array(
                'name' => $row['name'],
                'level' => $row['level'],
                'description' => $row['description'],
                'value' => $row['value'],
                'subcategory' => $row['subcategory']
            );
            $results[$type] = array(
                'title' => ucwords((string)$type),
                'admin' => $admin,
                'prefs' => $type_array[$type]
            );
        } // end while

        return $results;
    }

    /**
     * set_preferences
     * sets the prefs for this specific user
     */
    public function set_preferences(): void
    {
        $user_id    = Dba::escape($this->id);
        $sql        = "SELECT `preference`.`name`, `user_preference`.`value` FROM `preference`, `user_preference` WHERE `user_preference`.`user` = ? AND `user_preference`.`preference` = `preference`.`id` AND `preference`.`type` != 'system';";
        $db_results = Dba::read($sql, array($user_id));

        while ($row = Dba::fetch_assoc($db_results)) {
            $key               = $row['name'];
            $this->prefs[$key] = $row['value'];
        }
    }

    /**
     * get_favorites
     * returns an array of your $type favorites
     * @param string $type
     * @return array
     */
    public function get_favorites($type): array
    {
        $items   = array();
        $count   = AmpConfig::get('popular_threshold', 10);
        $results = Stats::get_user($count, $type, $this->id, 1);
        foreach ($results as $row) {
            $className = ObjectTypeToClassNameMapper::map($type);
            /** @var Song|Album|Artist $data */
            $data = new $className($row['object_id']);
            if ($data->isNew()) {
                continue;
            }
            $data->format();
            if ($type == 'song') {
                /** @var Song $data */
                $data->count = $row['count'];
            }
            if ($type == 'artist') {
                /** @var Artist $data */
                $data->f_name = $data->f_link;
            }
            $items[] = $data;
        } // end foreach

        return $items;
    }

    /**
     * is_logged_in
     * checks to see if $this user is logged in returns their current IP if they are logged in
     */
    public function is_logged_in(): ?string
    {
        $sql = (AmpConfig::get('perpetual_api_session'))
            ? "SELECT `id`, `ip` FROM `session` WHERE `username` = ? AND ((`expire` = 0 AND `type` = 'api') OR `expire` > ?);"
            : "SELECT `id`, `ip` FROM `session` WHERE `username` = ? AND `expire` > ?;";
        $db_results = Dba::read($sql, array($this->username, time()));

        if ($row = Dba::fetch_assoc($db_results)) {
            return $row['ip'] ?? null;
        }

        return null;
    }

    /**
     * has_access
     * this function checks to see if this user has access
     * to the passed action (pass a level requirement)
     * @param int $needed_level
     */
    public function has_access($needed_level): bool
    {
        if (AmpConfig::get('demo_mode')) {
            return true;
        }

        if ($this->access >= $needed_level) {
            return true;
        }

        return false;
    }

    /**
     * is_registered
     * Check if the user is registered
     */
    public static function is_registered(): bool
    {
        if (empty(Core::get_global('user'))) {
            return false;
        }
        if (!Core::get_global('user')->id) {
            return false;
        }

        if (!AmpConfig::get('use_auth') && Core::get_global('user')->access < 5) {
            return false;
        }

        return true;
    }

    /**
     * set_user_data
     * This updates some background data for user specific function
     * @param int $user_id
     * @param string $key
     * @param string|int $value
     */
    public static function set_user_data(int $user_id, string $key, $value): void
    {
        Dba::write("REPLACE INTO `user_data` SET `user` = ?, `key` = ?, `value` = ?;", array($user_id, $key, $value));
    }

    /**
     * get_user_data
     * This updates some background data for user specific function
     * @param int $user_id
     * @param string $key
     * @param string|int|null $default
     * @return array
     */
    public static function get_user_data($user_id, $key = null, $default = null): array
    {
        $sql    = "SELECT `key`, `value` FROM `user_data` WHERE `user` = ?";
        $params = array($user_id);
        if ($key) {
            $sql .= " AND `key` = ?";
            $params[] = $key;
        }

        $db_results = Dba::read($sql, $params);
        $results    = ($key !== null && $default !== null)
            ? array($key => $default)
            : array();
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[$row['key']] = $row['value'];
        }

        return $results;
    }

    /**
     * get_play_size
     * A user might be missing the play_size so it needs to be calculated
     * @param int $user_id
     */
    public static function get_play_size($user_id): int
    {
        $params = array($user_id);
        $total  = 0;
        $sql_s  = "SELECT IFNULL(SUM(`size`)/1024/1024, 0) AS `size` FROM `object_count` LEFT JOIN `song` ON `song`.`id`=`object_count`.`object_id` AND `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream' AND `object_count`.`user` = ?;";
        $db_s   = Dba::read($sql_s, $params);
        while ($results = Dba::fetch_assoc($db_s)) {
            $total = $total + (int)$results['size'];
        }
        $sql_v = "SELECT IFNULL(SUM(`size`)/1024/1024, 0) AS `size` FROM `object_count` LEFT JOIN `video` ON `video`.`id`=`object_count`.`object_id` AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_type` = 'video' AND `object_count`.`user` = ?;";
        $db_v  = Dba::read($sql_v, $params);
        while ($results = Dba::fetch_assoc($db_v)) {
            $total = $total + (int)$results['size'];
        }
        $sql_p = "SELECT IFNULL(SUM(`size`)/1024/1024, 0) AS `size` FROM `object_count`LEFT JOIN `podcast_episode` ON `podcast_episode`.`id`=`object_count`.`object_id` AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_type` = 'podcast_episode' AND `object_count`.`user` = ?;";
        $db_p  = Dba::read($sql_p, $params);
        while ($results = Dba::fetch_assoc($db_p)) {
            $total = $total + (int)$results['size'];
        }

        return $total;
    }

    /**
     * update
     * This function is an all encompassing update function that
     * calls the mini ones does all the error checking and all that
     * good stuff
     * @param array $data
     * @return int|false
     */
    public function update(array $data)
    {
        if (empty($data['username'])) {
            AmpError::add('username', T_('Username is required'));
        }

        if ($data['password1'] != $data['password2'] && !empty($data['password1'])) {
            AmpError::add('password', T_("Passwords do not match"));
        }

        if (AmpError::occurred()) {
            return false;
        }

        if (!isset($data['fullname_public'])) {
            $data['fullname_public'] = false;
        }

        foreach ($data as $name => $value) {
            if ($name == 'password1') {
                $name = 'password';
            } else {
                $value = scrub_in($value);
            }

            switch ($name) {
                case 'password':
                case 'access':
                case 'email':
                case 'username':
                case 'fullname':
                case 'fullname_public':
                case 'website':
                case 'state':
                case 'city':
                case 'catalog_filter_group':
                    if ($this->$name != $value) {
                        $function = 'update_' . $name;
                        $this->$function($value);
                    }
                    break;
                case 'clear_stats':
                    Stats::clear($this->id);
                    break;
            }
        }

        return $this->id;
    }

    /**
     * update_catalog_filter_group
     * Set a new filter group catalog filter
     * @param int $new_filter
     */
    public function update_catalog_filter_group($new_filter): void
    {
        $sql = "UPDATE `user` SET `catalog_filter_group` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating catalog access group', 4);

        Dba::write($sql, array($new_filter, $this->id));
    }

    /**
     * update_username
     * updates their username
     * @param string $new_username
     */
    public function update_username($new_username): void
    {
        $sql            = "UPDATE `user` SET `username` = ? WHERE `id` = ?";
        $this->username = $new_username;

        debug_event(self::class, 'Updating username', 4);

        Dba::write($sql, array($new_username, $this->id));
    }

    /**
     * update_validation
     * This is used by the registration mumbojumbo
     * Use this function to update the validation key
     * NOTE: crap this doesn't have update_item the humanity of it all
     * @param string $new_validation
     * @return PDOStatement|bool
     */
    public function update_validation($new_validation)
    {
        $sql              = "UPDATE `user` SET `validation` = ?, `disabled`='1' WHERE `id` = ?";
        $db_results       = Dba::write($sql, array($new_validation, $this->id));
        $this->validation = $new_validation;

        return $db_results;
    }

    /**
     * update_fullname
     * updates their fullname
     * @param string $new_fullname
     */
    public function update_fullname($new_fullname): void
    {
        $sql = "UPDATE `user` SET `fullname` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating fullname', 4);

        Dba::write($sql, array($new_fullname, $this->id));
    }

    /**
     * update_fullname_public
     * updates their fullname public
     * @param bool|string $new_fullname_public
     */
    public function update_fullname_public($new_fullname_public): void
    {
        $sql = "UPDATE `user` SET `fullname_public` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating fullname public', 4);

        Dba::write($sql, array($new_fullname_public ? '1' : '0', $this->id));
    }

    /**
     * update_email
     * updates their email address
     * @param string $new_email
     */
    public function update_email($new_email): void
    {
        $sql = "UPDATE `user` SET `email` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating email', 4);

        Dba::write($sql, array($new_email, $this->id));
    }

    /**
     * update_website
     * updates their website address
     * @param string $new_website
     */
    public function update_website($new_website): void
    {
        $new_website = rtrim((string)$new_website, "/");
        $sql         = "UPDATE `user` SET `website` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating website', 4);

        Dba::write($sql, array($new_website, $this->id));
    }

    /**
     * update_state
     * updates their state
     * @param string $new_state
     */
    public function update_state($new_state): void
    {
        $sql = "UPDATE `user` SET `state` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating state', 4);

        Dba::write($sql, array($new_state, $this->id));
    }

    /**
     * update_city
     * updates their city
     * @param string $new_city
     */
    public function update_city($new_city): void
    {
        $sql = "UPDATE `user` SET `city` = ? WHERE `id` = ?";

        debug_event(self::class, 'Updating city', 4);

        Dba::write($sql, array($new_city, $this->id));
    }

    /**
     * update_counts for individual users
     */
    public static function update_counts(): void
    {
        $catalog_disable = AmpConfig::get('catalog_disable');
        $catalog_filter  = AmpConfig::get('catalog_filter');
        $sql             = "SELECT `id` FROM `user`";
        $db_results      = Dba::read($sql);
        $user_list       = array();
        while ($results = Dba::fetch_assoc($db_results)) {
            $user_list[] = (int)$results['id'];
        }
        // TODO $user_list[] = -1; // make sure the System / Guest user gets a count as well
        if (!$catalog_filter) {
            // no filter means no need for filtering or counting per user
            $count_array   = array('song', 'video', 'podcast_episode', 'artist', 'album', 'search', 'playlist', 'live_stream', 'podcast', 'user', 'catalog', 'label', 'tag', 'share', 'license', 'album_disk', 'items', 'time', 'size');
            $server_counts = Catalog::get_server_counts(0);
            foreach ($user_list as $user_id) {
                debug_event(self::class, 'Update counts for ' . $user_id, 5);
                foreach ($server_counts as $table => $count) {
                    if (in_array($table, $count_array)) {
                        self::set_user_data($user_id, $table, $count);
                    }
                }
            }

            return;
        }

        $count_array = array('song', 'video', 'podcast_episode', 'artist', 'album', 'search', 'playlist', 'live_stream', 'podcast', 'user', 'catalog', 'label', 'tag', 'share', 'license');
        foreach ($user_list as $user_id) {
            $catalog_array = self::get_user_catalogs($user_id);
            debug_event(self::class, 'Update counts for ' . $user_id, 5);
            // get counts per user (filtered catalogs aren't counted)
            foreach ($count_array as $table) {
                $sql = (in_array($table, array('search', 'user', 'license')))
                    ? "SELECT COUNT(`id`) FROM `$table`"
                    : "SELECT COUNT(`id`) FROM `$table` WHERE" . Catalog::get_user_filter($table, $user_id);
                $db_results = Dba::read($sql);
                $row        = Dba::fetch_row($db_results);

                self::set_user_data($user_id, $table, (int)($row[0] ?? 0));
            }
            // tables with media items to count, song-related tables and the rest
            $media_tables = array('song', 'video', 'podcast_episode');
            $items        = 0;
            $time         = 0;
            $size         = 0;
            foreach ($media_tables as $table) {
                if (empty($catalog_array)) {
                    continue;
                }
                $sql = ($catalog_disable)
                    ? "SELECT COUNT(`id`), IFNULL(SUM(`time`), 0), IFNULL(SUM(`size`)/1024/1024, 0) FROM `$table` WHERE `catalog` IN (" . implode(',', $catalog_array) . ") AND `$table`.`enabled`='1';"
                    : "SELECT COUNT(`id`), IFNULL(SUM(`time`), 0), IFNULL(SUM(`size`)/1024/1024, 0) FROM `$table` WHERE `catalog` IN (" . implode(',', $catalog_array) . ");";
                $db_results = Dba::read($sql);
                $row        = Dba::fetch_row($db_results);
                // save the object and add to the current size
                $items += (int)($row[0] ?? 0);
                $time += (int)($row[1] ?? 0);
                $size += (int)($row[2] ?? 0);
                self::set_user_data($user_id, $table, (int)($row[0] ?? 0));
            }
            self::set_user_data($user_id, 'items', $items);
            self::set_user_data($user_id, 'time', $time);
            self::set_user_data($user_id, 'size', $size);
            // album_disk counts
            $sql        = "SELECT COUNT(DISTINCT `album_disk`.`id`) AS `count` FROM `album_disk` LEFT JOIN `album` ON `album_disk`.`album_id` = `album`.`id` LEFT JOIN `catalog` ON `catalog`.`id` = `album`.`catalog` LEFT JOIN `artist_map` ON `artist_map`.`object_id` = `album`.`id` WHERE `artist_map`.`object_type` = 'album' AND `catalog`.`enabled` = '1' AND" . Catalog::get_user_filter('album', $user_id);
            $db_results = Dba::read($sql);
            $row        = Dba::fetch_row($db_results);
            self::set_user_data($user_id, 'album_disk', (int)($row[0] ?? 0));
        }
    }

    /**
     * disable
     * This disables the current user
     */
    public function disable(): bool
    {
        // Make sure we aren't disabling the last admin
        $sql        = "SELECT `id` FROM `user` WHERE `disabled` = '0' AND `id` != '" . $this->id . "' AND `access`='100'";
        $db_results = Dba::read($sql);

        if (!Dba::num_rows($db_results)) {
            return false;
        }

        $sql = "UPDATE `user` SET `disabled`='1' WHERE `id`='" . $this->id . "'";
        Dba::write($sql);

        // Delete any sessions they may have
        $sql = "DELETE FROM `session` WHERE `username`='" . Dba::escape($this->username) . "'";
        Dba::write($sql);

        return true;
    }

    /**
     * update_access
     * updates their access level
     */
    public function update_access(int $new_access): bool
    {
        // There must always be at least one admin left if you're reducing access
        if ($new_access < 100) {
            $sql        = "SELECT `id` FROM `user` WHERE `access`='100' AND `id` != '$this->id'";
            $db_results = Dba::read($sql);
            if (!Dba::num_rows($db_results)) {
                return false;
            }
        }

        $new_access = Dba::escape($new_access);
        $sql        = "UPDATE `user` SET `access` = ? WHERE `id` = ?;";

        debug_event(self::class, 'Updating access level for ' . $this->id, 4);

        Dba::write($sql, array($new_access, $this->id));

        return true;
    }

    /**
     * save_mediaplay
     * @param User $user
     * @param Song $media
     */
    public static function save_mediaplay($user, $media): void
    {
        foreach (Plugin::get_plugins('save_mediaplay') as $plugin_name) {
            try {
                $plugin = new Plugin($plugin_name);
                if ($plugin->_plugin !== null && $plugin->load($user)) {
                    debug_event(self::class, 'save_mediaplay... ' . $plugin->_plugin->name, 5);
                    $plugin->_plugin->save_mediaplay($media);
                }
            } catch (Exception $error) {
                debug_event(self::class, 'save_mediaplay plugin error: ' . $error->getMessage(), 1);
            }
        }
    }

    /**
     * create
     * inserts a new user into Ampache
     * @param string $username
     * @param string $fullname
     * @param string $email
     * @param string $website
     * @param string $password
     * @param int $access
     * @param string $state
     * @param string $city
     * @param bool $disabled
     * @param bool $encrypted
     * @return int
     */
    public static function create(
        $username,
        $fullname,
        $email,
        $website,
        $password,
        $access,
        $catalog_filter_group = 0,
        $state = '',
        $city = '',
        $disabled = false,
        $encrypted = false
    ): int {
        // don't try to overwrite users that already exist
        if (static::getUserRepository()->idByUsername($username) > 0 || static::getUserRepository()->idByEmail($email) > 0) {
            return 0;
        }
        $website = rtrim((string)$website, "/");
        if (!$encrypted) {
            $password = hash('sha256', $password);
        }
        $disabled = $disabled ? 1 : 0;

        // Just in case a zero value slipped in from upper layers...
        $catalog_filter_group = $catalog_filter_group ?? 0;

        /* Now Insert this new user */
        $sql    = "INSERT INTO `user` (`username`, `disabled`, `fullname`, `email`, `password`, `access`, `catalog_filter_group`, `create_date`";
        $params = array($username, $disabled, $fullname, $email, $password, $access, $catalog_filter_group, time());

        if (!empty($website)) {
            $sql .= ", `website`";
            $params[] = $website;
        }
        if (!empty($state)) {
            $sql .= ", `state`";
            $params[] = $state;
        }
        if (!empty($city)) {
            $sql .= ", `city`";
            $params[] = $city;
        }
        $user_create_streamtoken = AmpConfig::get('user_create_streamtoken', false);
        if ($user_create_streamtoken) {
            $sql .= ", `streamtoken`";
            $params[] = bin2hex(random_bytes(20));
        }

        $sql .= ") VALUES(?, ?, ?, ?, ?, ?, ?, ?";

        if (!empty($website)) {
            $sql .= ", ?";
        }
        if (!empty($state)) {
            $sql .= ", ?";
        }
        if (!empty($city)) {
            $sql .= ", ?";
        }
        if ($user_create_streamtoken) {
            $sql .= ", ?";
        }

        $sql .= ")";
        $db_results = Dba::write($sql, $params);

        if (!$db_results) {
            return 0;
        }
        // Get the insert_id
        $insert_id = (int)Dba::insert_id();

        // Populates any missing preferences, in this case all of them
        self::fix_preferences($insert_id);

        Catalog::count_table('user');

        return (int)$insert_id;
    }

    /**
     * update_password
     * updates a users password
     * @param string $new_password
     * @param string $hashed_password
     */
    public function update_password($new_password, $hashed_password = null): void
    {
        debug_event(self::class, 'Updating password', 1);
        if (!$hashed_password) {
            $hashed_password = hash('sha256', $new_password);
        }

        $escaped_password = Dba::escape($hashed_password);
        $sql              = "UPDATE `user` SET `password` = ? WHERE `id` = ?";
        $db_results       = Dba::write($sql, array($escaped_password, $this->id));

        // Clear this (temp fix)
        if ($db_results) {
            unset($_SESSION['userdata']['password']);
        }
    }

    /**
     * format
     * This function sets up the extra variables we need when we are displaying a
     * user for an admin, these should not be normally called when creating a
     * user object
     * @param bool $details
     */
    public function format($details = true): void
    {
        if ($this->isNew()) {
            return;
        }
        /* If they have a last seen date */
        $this->f_last_seen = (!$this->last_seen)
            ? T_('Never')
            : get_datetime((int)$this->last_seen);

        /* If they have a create date */
        $this->f_create_date = (!$this->create_date)
            ? T_('Unknown')
            : get_datetime((int)$this->create_date);

        if ($details) {
            $user_data = self::get_user_data($this->id, 'play_size');
            if (!isset($user_data['play_size'])) {
                $total = self::get_play_size($this->id);
                // set the value for next time
                self::set_user_data($this->id, 'play_size', $total);
                $user_data['play_size'] = $total;
            }

            $this->f_usage = Ui::format_bytes($user_data['play_size'], 2, 2);

            $recent_user_ip = $this->getIpHistoryRepository()->getRecentIpForUser($this);
            // Get Users Last ip
            if ($recent_user_ip !== null) {
                $this->ip_history = ($recent_user_ip !== '' && filter_var($recent_user_ip, FILTER_VALIDATE_IP)) ? $recent_user_ip : T_('Invalid');
            } else {
                $this->ip_history = T_('Not Enough Data');
            }
        }

        $avatar = $this->get_avatar();
        if (!empty($avatar['url'])) {
            $this->f_avatar = '<img src="' . $avatar['url'] . '" title="' . $avatar['title'] . '"' . ' width="256px" height="auto" />';
        }
        if (!empty($avatar['url_mini'])) {
            $this->f_avatar_mini = '<img src="' . $avatar['url_mini'] . '" title="' . $avatar['title'] . '" style="width: 32px; height: 32px;" />';
        }
        if (!empty($avatar['url_medium'])) {
            $this->f_avatar_medium = '<img src="' . $avatar['url_medium'] . '" title="' . $avatar['title'] . '" style="width: 64px; height: 64px;" />';
        }
    }

    /**
     * does the item have art?
     */
    public function has_art(): bool
    {
        if ($this->has_art === null) {
            $this->has_art = Art::has_db($this->id, 'user');
        }

        return $this->has_art;
    }

    /**
     * access_name_to_level
     * This takes the access name for the user and returns the level
     * @param string $name
     */
    public static function access_name_to_level($name): int
    {
        // FIXME why is manager not here? (AccessLevelEnum::LEVEL_CONTENT_MANAGER;)
        switch ($name) {
            case 'admin':
                return AccessLevelEnum::LEVEL_ADMIN;
            case 'user':
                return AccessLevelEnum::LEVEL_USER;
            case 'manager':
                return AccessLevelEnum::LEVEL_MANAGER;
            case 'guest':
                return AccessLevelEnum::LEVEL_GUEST;
            default:
                return AccessLevelEnum::LEVEL_DEFAULT;
        }
    }

    /**
     * access_level_to_name
     * This takes the access level for the user and returns the translated name for that level
     * @param string $level
     */
    public static function access_level_to_name($level): string
    {
        switch ($level) {
            case '100':
                return T_('Admin');
            case '75':
                return T_('Catalog Manager');
            case '50':
                return T_('Content Manager');
            case '25':
                return T_('User');
            case '5':
                return T_('Guest');
            default:
                return T_('Unknown');
        }
    }

    /**
     * fix_preferences
     * This is the new fix_preferences function, it does the following
     * Remove Duplicates from user, add in missing
     * If -1 is passed it also removes duplicates from the `preferences`
     * table.
     * @param int $user_id
     */
    public static function fix_preferences($user_id): void
    {
        // Check default group (autoincrement starts at 1 so force it to be 0)
        $sql        = "SELECT `id`, `name` FROM `catalog_filter_group` WHERE `name` = 'DEFAULT';";
        $db_results = Dba::read($sql);
        $row        = Dba::fetch_assoc($db_results);
        if (!array_key_exists('id', $row) || ($row['id'] ?? '') != 0) {
            debug_event(self::class, 'fix_preferences restore DEFAULT catalog_filter_group', 2);
            // reinsert missing default group
            $sql = "INSERT IGNORE INTO `catalog_filter_group` (`name`) VALUES ('DEFAULT');";
            Dba::write($sql);
            $sql = "UPDATE `catalog_filter_group` SET `id` = 0 WHERE `name` = 'DEFAULT';";
            Dba::write($sql);
            $sql        = "SELECT MAX(`id`) AS `filter_count` FROM `catalog_filter_group`;";
            $db_results = Dba::read($sql);
            $row        = Dba::fetch_assoc($db_results);
            $increment  = (int)($row['filter_count'] ?? 0) + 1;
            $sql        = "ALTER TABLE `catalog_filter_group` AUTO_INCREMENT = $increment;";
            Dba::write($sql);
        }
        // Make sure all current catalogs are in the default group map
        $sql = "INSERT IGNORE INTO `catalog_filter_group_map` (`group_id`, `catalog_id`, `enabled`) SELECT 0, `catalog`.`id`, `catalog`.`enabled` FROM `catalog` WHERE `catalog`.`id` NOT IN (SELECT `catalog_id` AS `id` FROM `catalog_filter_group_map` WHERE `group_id` = 0);";
        Dba::write($sql);

        /* Get All Preferences for the current user */
        $sql          = "SELECT * FROM `user_preference` WHERE `user` = ?";
        $db_results   = Dba::read($sql, array($user_id));
        $results      = array();
        $zero_results = array();

        while ($row = Dba::fetch_assoc($db_results)) {
            $pref_id = $row['preference'];
            // Check for duplicates
            if (isset($results[$pref_id])) {
                $sql = "DELETE FROM `user_preference` WHERE `user` = ? AND `preference` = ? AND `value` = ?;";
                Dba::write($sql, array($user_id, $row['preference'], $row['value']));
            } else {
                // if its set
                $results[$pref_id] = 1;
            }
        } // end while

        // If your user is missing preferences we copy the value from system (Except for plugins and system prefs)
        if ($user_id != '-1') {
            $sql        = "SELECT `user_preference`.`preference`, `user_preference`.`value` FROM `user_preference`, `preference` WHERE `user_preference`.`preference` = `preference`.`id` AND `user_preference`.`user`='-1' AND `preference`.`category` NOT IN ('plugins', 'system');";
            $db_results = Dba::read($sql);
            /* While through our base stuff */
            while ($row = Dba::fetch_assoc($db_results)) {
                $key                = $row['preference'];
                $zero_results[$key] = $row['value'];
            }
        } // if not user -1

        // get me _EVERYTHING_
        $sql = "SELECT * FROM `preference`";

        // If not system, exclude system... *gasp*
        if ($user_id != '-1') {
            $sql .= " WHERE `category` !='system';";
        }
        $db_results = Dba::read($sql);

        while ($row = Dba::fetch_assoc($db_results)) {
            $key = $row['id'];

            // Check if this preference is set
            if (!isset($results[$key])) {
                if (isset($zero_results[$key])) {
                    $row['value'] = $zero_results[$key];
                }
                $sql = "INSERT INTO user_preference (`user`, `preference`, `value`) VALUES (?, ?, ?)";
                Dba::write($sql, array($user_id, $key, $row['value']));
            }
        } // while preferences
    }

    /**
     * delete
     * deletes this user and everything associated with it. This will affect
     * ratings and total stats
     */
    public function delete(): bool
    {
        // Before we do anything make sure that they aren't the last admin
        if ($this->has_access(100)) {
            $sql        = "SELECT `id` FROM `user` WHERE `access`='100' AND id != ?";
            $db_results = Dba::read($sql, array($this->id));
            if (!Dba::num_rows($db_results)) {
                return false;
            }
        } // if this is an admin check for others

        // Delete the user itself
        $sql = "DELETE FROM `user` WHERE `id` = ?";
        Dba::write($sql, array($this->id));

        // Delete custom access settings
        $sql = "DELETE FROM `access_list` WHERE `user` = ?";
        Dba::write($sql, array($this->id));

        $sql = "DELETE FROM `session` WHERE `username` = ?";
        Dba::write($sql, array($this->username));

        Catalog::count_table('user');
        static::getUserRepository()->collectGarbage();

        return true;
    }

    /**
     * is_online
     * delay how long since last_seen in seconds default of 20 min
     * calculates difference between now and last_seen
     * if less than delay, we consider them still online
     * @param int $delay
     */
    public function is_online($delay = 1200): bool
    {
        return time() - $this->last_seen <= $delay;
    }

    /**
     * get_recently_played
     * This gets the recently played items for this user respecting
     * the limit passed. ger recent by default or oldest if $newest is false.
     * @param string $type
     * @param int $count
     * @param int $offset
     * @param bool $newest
     * @return int[]
     */
    public function get_recently_played($type, $count, $offset = 0, $newest = true, $count_type = 'stream'): array
    {
        $ordersql = ($newest === true) ? 'DESC' : 'ASC';
        $limit    = ($offset < 1) ? $count : $offset . "," . $count;

        $sql        = "SELECT `object_id`, MAX(`date`) AS `date` FROM `object_count` WHERE `object_type` = ? AND `user` = ? AND `count_type` = ? GROUP BY `object_id` ORDER BY `date` " . $ordersql . " LIMIT " . $limit . " ";
        $db_results = Dba::read($sql, array($type, $this->id, $count_type));

        $results = array();
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['object_id'];
        }

        return $results;
    }

    /**
     * Get item fullname.
     */
    public function get_fullname(): ?string
    {
        if (!isset($this->f_name)) {
            $this->f_name = ($this->fullname_public)
                ? $this->fullname
                : $this->username;
        }

        return $this->f_name;
    }

    /**
     * Get item link.
     */
    public function get_link(): ?string
    {
        // don't do anything if it's formatted
        if (!isset($this->link) && $this->id > 0) {
            $web_path   = AmpConfig::get('web_path');
            $this->link = $web_path . '/stats.php?action=show_user&user_id=' . $this->id;
        }

        return $this->link;
    }

    /**
     * Get item f_link.
     */
    public function get_f_link(): string
    {
        if ($this->f_link === null) {
            if ($this->getId() === 0) {
                $this->f_link = '';
            } else {
                $this->f_link = '<a href="' . $this->get_link() . '">' . scrub_out($this->get_fullname()) . '</a>';
            }
        }

        return $this->f_link;
    }

    /**
     * Get item name based on whether they allow public fullname access.
     * @param int $user_id
     */
    public static function get_username($user_id): string
    {
        $users = static::getUserRepository()->getValidArray(true);

        return (isset($users[$user_id]))
            ? $users[$user_id]
            : T_('System');
    }

    /**
     * get_avatar
     * Get the user avatar
     * @param bool $local
     * @return array
     */
    public function get_avatar($local = false): array
    {
        $avatar          = array();
        $avatar['title'] = T_('User avatar');
        if ($this->has_art()) {
            $avatar['url']        = ($local ? AmpConfig::get('local_web_path') : AmpConfig::get('web_path')) . '/image.php?object_type=user&object_id=' . $this->id;
            $avatar['url_mini']   = $avatar['url'];
            $avatar['url_medium'] = $avatar['url'];
            $avatar['url'] .= '&thumb=4';
            $avatar['url_mini'] .= '&thumb=5';
            $avatar['url_medium'] .= '&thumb=3';
        } else {
            foreach (Plugin::get_plugins('get_avatar_url') as $plugin_name) {
                $plugin = new Plugin($plugin_name);
                if ($plugin->_plugin !== null && $plugin->load(Core::get_global('user'))) {
                    $avatar['url'] = $plugin->_plugin->get_avatar_url($this);
                    if (!empty($avatar['url'])) {
                        $avatar['url_mini']   = $plugin->_plugin->get_avatar_url($this, 32);
                        $avatar['url_medium'] = $plugin->_plugin->get_avatar_url($this, 64);
                        $avatar['title'] .= ' (' . $plugin->_plugin->name . ')';
                        break;
                    }
                }
            }
        }

        if (!array_key_exists('url', $avatar)) {
            $avatar['url']        = ($local ? AmpConfig::get('local_web_path') : AmpConfig::get('web_path')) . '/images/blankuser.png';
            $avatar['url_mini']   = $avatar['url'];
            $avatar['url_medium'] = $avatar['url'];
        }

        return $avatar;
    }

    /**
     * @param string $data
     * @param string $mime
     */
    public function update_avatar($data, $mime = ''): bool
    {
        debug_event(self::class, 'Updating avatar for ' . $this->id, 4);

        $art = new Art($this->id, 'user');

        return $art->insert($data, $mime);
    }

    /**
     * upload_avatar
     */
    public function upload_avatar(): bool
    {
        $upload = array();
        if (!empty($_FILES['avatar']['tmp_name']) && $_FILES['avatar']['size'] <= AmpConfig::get('max_upload_size')) {
            $path_info      = pathinfo($_FILES['avatar']['name']);
            $upload['file'] = $_FILES['avatar']['tmp_name'];
            $upload['mime'] = 'image/' . ($path_info['extension'] ?? '');
            if (!in_array(strtolower($path_info['extension'] ?? ''), Art::VALID_TYPES)) {
                return false;
            }

            $image_data = Art::get_from_source($upload, 'user');
            if ($image_data !== '') {
                return $this->update_avatar($image_data, $upload['mime']);
            }
        }

        return true; // only worry about failed uploads
    }

    public function deleteAvatar(): void
    {
        $art = new Art($this->id, 'user');
        $art->reset();
    }

    public function deleteStreamToken(): void
    {
        $sql = "UPDATE `user` SET `streamtoken` = NULL WHERE `id` = ?;";
        Dba::write($sql, array($this->id));
    }

    public function deleteRssToken(): void
    {
        $sql = "UPDATE `user` SET `rsstoken` = NULL WHERE `id` = ?;";
        Dba::write($sql, array($this->id));
    }

    public function deleteApiKey(): void
    {
        $sql = "UPDATE `user` SET `apikey` = NULL WHERE `id` = ?;";
        Dba::write($sql, array($this->id));
    }

    /**
     * rebuild_all_preferences
     * This rebuilds the user preferences for all installed users, called by the plugin functions
     */
    public static function rebuild_all_preferences(): void
    {
        // Garbage collection
        $sql = "DELETE `user_preference`.* FROM `user_preference` LEFT JOIN `user` ON `user_preference`.`user` = `user`.`id` WHERE `user_preference`.`user` != -1 AND `user`.`id` IS NULL;";
        Dba::write($sql);
        // delete system prefs from users
        $sql = "DELETE `user_preference`.* FROM `user_preference` LEFT JOIN `preference` ON `user_preference`.`preference` = `preference`.`id` WHERE `user_preference`.`user` != -1 AND `preference`.`category` = 'system';";
        Dba::write($sql);

        // How many preferences should we have?
        $sql        = "SELECT COUNT(`id`) AS `pref_count` FROM `preference` WHERE `category` != 'system';";
        $db_results = Dba::read($sql);
        $row        = Dba::fetch_assoc($db_results);
        $pref_count = (int)$row['pref_count'];
        // Get only users who have less preferences than excepted otherwise it would have significant performance issue with large user database
        $sql        = "SELECT `user` FROM `user_preference` GROUP BY `user` HAVING COUNT(*) < $pref_count";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            self::fix_preferences($row['user']);
        }
        // Fix the system user preferences
        self::fix_preferences(-1);
    }

    /**
     * stream_control
     * Check all stream control plugins
     * @param array $media_ids
     * @param User|null $user
     */
    public static function stream_control($media_ids, ?User $user = null): bool
    {
        if ($user === null) {
            $user = Core::get_global('user');
            if (!$user instanceof User) {
                return false;
            }
        }

        foreach (Plugin::get_plugins('stream_control') as $plugin_name) {
            $plugin = new Plugin($plugin_name);
            if ($plugin->_plugin !== null && $plugin->load($user)) {
                if (!$plugin->_plugin->stream_control($media_ids)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Returns the users internal username
     */
    public function getUsername(): string
    {
        return $this->username ?? '';
    }

    /**
     * Returns a concatenated version of several names
     *
     * In some cases (e.g. admin backend), we want to be as verbose as possible,
     * so show the username and the users full-name (display name).
     */
    public function getFullDisplayName(): string
    {
        return sprintf('%s (%s)', $this->username, $this->fullname);
    }

    /**
     * Returns the value of a certain user-preference
     *
     * @return int|string
     */
    public function getPreferenceValue(string $preferenceName)
    {
        return Preference::get_by_user($this->getId(), $preferenceName);
    }

    /**
     * @deprecated inject dependency
     */
    private function getIpHistoryRepository(): IpHistoryRepositoryInterface
    {
        global $dic;

        return $dic->get(IpHistoryRepositoryInterface::class);
    }

    /**
     * @deprecated inject dependency
     */
    private static function getUserRepository(): UserRepositoryInterface
    {
        global $dic;

        return $dic->get(UserRepositoryInterface::class);
    }
}