ampache/ampache

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

Summary

Maintainability
F
3 wks
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\Config\ConfigContainerInterface;
use Ampache\Config\ConfigurationKeyEnum;
use Ampache\Module\Art\Collector\ArtCollectorInterface;
use Ampache\Module\Authorization\Access;
use Ampache\Module\Catalog\Catalog_beets;
use Ampache\Module\Catalog\Catalog_beetsremote;
use Ampache\Module\Catalog\Catalog_dropbox;
use Ampache\Module\Catalog\Catalog_local;
use Ampache\Module\Catalog\Catalog_remote;
use Ampache\Module\Catalog\Catalog_Seafile;
use Ampache\Module\Catalog\Catalog_subsonic;
use Ampache\Module\Catalog\CatalogLoader;
use Ampache\Module\Catalog\GarbageCollector\CatalogGarbageCollectorInterface;
use Ampache\Module\Metadata\MetadataEnabledInterface;
use Ampache\Module\Metadata\MetadataManagerInterface;
use Ampache\Module\Song\Tag\SongTagWriterInterface;
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\Recommendation;
use Ampache\Module\Util\Ui;
use Ampache\Module\Util\UtilityFactoryInterface;
use Ampache\Module\Util\VaInfo;
use Ampache\Repository\AlbumRepositoryInterface;
use Ampache\Repository\ArtistRepositoryInterface;
use Ampache\Repository\BookmarkRepositoryInterface;
use Ampache\Repository\LabelRepositoryInterface;
use Ampache\Repository\LicenseRepositoryInterface;
use Ampache\Repository\MetadataRepositoryInterface;
use Ampache\Repository\PodcastRepositoryInterface;
use Ampache\Repository\ShareRepositoryInterface;
use Ampache\Repository\ShoutRepositoryInterface;
use Ampache\Repository\SongRepositoryInterface;
use Ampache\Repository\UserRepositoryInterface;
use Ampache\Repository\WantedRepositoryInterface;
use DateTime;
use Exception;
use Generator;
use PDOStatement;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionException;
use RegexIterator;

/**
 * This class handles all actual work in regards to the catalog,
 * it contains functions for creating/listing/updated the catalogs.
 */
abstract class Catalog extends database_object
{
    protected const DB_TABLENAME = 'catalog';

    /**
     * @var array<string, class-string>
     */
    public const CATALOG_TYPES = [
        'beets' => Catalog_beets::class,
        'beetsremote' => Catalog_beetsremote::class,
        'dropbox' => Catalog_dropbox::class,
        'local' => Catalog_local::class,
        'remote' => Catalog_remote::class,
        'seafile' => Catalog_Seafile::class,
        'subsonic' => Catalog_subsonic::class,
    ];

    /**
     * @var array{
     *  album: int,
     *  album_disk: int,
     *  album_group: int,
     *  artist: int,
     *  catalog: int,
     *  items: int,
     *  label: int,
     *  license: int,
     *  live_stream: int,
     *  playlist: int,
     *  podcast: int,
     *  podcast_episode: int,
     *  search: int,
     *  share: int,
     *  size: int,
     *  song: int,
     *  tag: int,
     *  time: int,
     *  user: int,
     *  video: int
     * }
     */
    private const SERVER_COUNTS = [
        'album' => 0,
        'album_disk' => 0,
        'album_group' => 0,
        'artist' => 0,
        'catalog' => 0,
        'items' => 0,
        'label' => 0,
        'license' => 0,
        'live_stream' => 0,
        'playlist' => 0,
        'podcast' => 0,
        'podcast_episode' => 0,
        'search' => 0,
        'share' => 0,
        'size' => 0,
        'song' => 0,
        'tag' => 0,
        'time' => 0,
        'user' => 0,
        'video' => 0
    ];

    public int $id = 0;
    public ?string $name;
    public ?string $catalog_type;
    public int $last_update;
    public ?int $last_clean;
    public int $last_add;
    public bool $enabled;
    public ?string $rename_pattern = '';
    public ?string $sort_pattern   = '';
    public ?string $gather_types   = '';

    /**
     * @var string $key
     */
    public $key;
    /**
     * @var null|string $f_name
     */
    public $f_name;
    /**
     * @var null|string $link
     */
    public $link;
    /**
     * @var null|string $f_link
     */
    public $f_link;
    /**
     * @var null|string $f_update
     */
    public $f_update;
    /**
     * @var null|string $f_add
     */
    public $f_add;
    /**
     * @var null|string $f_clean
     */
    public $f_clean;
    /**
     * alias for catalog paths, urls, etc etc
     * @var null|string $f_full_info
     */
    public $f_full_info;
    /**
     * alias for catalog paths, urls, etc etc
     * @var null|string $f_info
     */
    public $f_info;

    /**
     * This is a private var that's used during catalog builds
     * @var array $_playlists
     */
    protected $_playlists = array();

    /**
     * Cache all files in catalog for quick lookup during add
     * @var array $_filecache
     */
    protected $_filecache = array();

    // Used in functions
    /**
     * @var array $albums
     */
    protected static $albums = array();
    /**
     * @var array $artists
     */
    protected static $artists = array();
    /**
     * @var array $tags
     */
    protected static $tags = array();

    /**
     * get_path
     */
    abstract public function get_path(): string;

    /**
     * get_type
     */
    abstract public function get_type(): string;

    /**
     * get_description
     */
    abstract public function get_description(): string;

    /**
     * get_version
     */
    abstract public function get_version(): string;

    /**
     * get_create_help
     */
    abstract public function get_create_help(): string;

    /**
     * is_installed
     */
    abstract public function is_installed(): bool;

    /**
     * install
     */
    abstract public function install(): bool;

    /**
     * @param array $options
     */
    abstract public function add_to_catalog($options = null): int;

    /**
     * verify_catalog_proc
     */
    abstract public function verify_catalog_proc(): int;

    /**
     * clean_catalog_proc
     */
    abstract public function clean_catalog_proc(): int;

    /**
     * @return array
     */
    abstract public function check_catalog_proc(): array;

    /**
     * @param string $new_path
     */
    abstract public function move_catalog_proc($new_path): bool;

    /**
     * cache_catalog_proc
     */
    abstract public function cache_catalog_proc(): bool;

    /**
     * @return array
     */
    abstract public function catalog_fields(): array;

    /**
     * @param string $file_path
     */
    abstract public function get_rel_path($file_path): string;

    /**
     * @param Song|Podcast_Episode|Video $media
     * @return null|array{
     *  file_path: string,
     *  file_name: string,
     *  file_size: int,
     *  file_type: string
     * }
     */
    abstract public function prepare_media($media): ?array;

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

    /**
     * @param Song|Podcast_Episode|Video $media
     */
    public function getRemoteStreamingUrl($media): ?string
    {
        return null;
    }

    /**
     * Check if the catalog is ready to perform actions (configuration completed, ...)
     */
    public function isReady(): bool
    {
        return true;
    }

    /**
     * Show a message to make the catalog ready.
     */
    public function show_ready_process()
    {
        // Do nothing.
    }

    /**
     * Perform the last step process to make the catalog ready.
     */
    public function perform_ready()
    {
        // Do nothing.
    }

    /**
     * uninstall
     * This removes the remote catalog
     */
    public function uninstall(): void
    {
        $sql = "DELETE FROM `catalog` WHERE `catalog_type` = ?";
        Dba::query($sql, array($this->get_type()));

        $sql = "DROP TABLE `catalog_" . $this->get_type() . "`";
        Dba::query($sql);
    }

    /**
     * Create a catalog from its id.
     * @param int $catalog_id
     */
    public static function create_from_id($catalog_id): ?Catalog
    {
        $sql        = 'SELECT `catalog_type` FROM `catalog` WHERE `id` = ?';
        $db_results = Dba::read($sql, array($catalog_id));
        $row        = Dba::fetch_assoc($db_results);
        if (empty($row)) {
            return null;
        }

        return self::create_catalog_type($row['catalog_type'], $catalog_id);
    }

    /**
     * create_catalog_type
     * This function attempts to create a catalog type
     * @param string $type
     * @param int $catalog_id
     */
    public static function create_catalog_type($type, $catalog_id = 0): ?Catalog
    {
        if (!$type) {
            return null;
        }

        $controller = self::CATALOG_TYPES[$type] ?? null;
        if ($controller === null) {
            /* Throw Error Here */
            debug_event(__CLASS__, 'Unable to load ' . $type . ' catalog type', 2);

            return null;
        } // include

        /** @var Catalog_beets|Catalog_beetsremote|Catalog_dropbox|Catalog_local|Catalog_remote|Catalog_Seafile|Catalog_subsonic $controller */
        $catalog = ($catalog_id > 0)
            ? new $controller($catalog_id)
            : new $controller();

        if (!($catalog instanceof Catalog)) {
            debug_event(__CLASS__, $type . ' not an instance of Catalog abstract, unable to load', 1);

            return null;
        }
        // identify if it's actually enabled
        $sql        = 'SELECT `enabled` FROM `catalog` WHERE `id` = ?';
        $db_results = Dba::read($sql, array($catalog->id));

        while ($results = Dba::fetch_assoc($db_results)) {
            $catalog->enabled = $results['enabled'];
        }

        return $catalog;
    }

    /**
     * get_catalog_filters
     * This returns the filters, sorting by name
     *
     * @return Generator<array{id: int, name: string}>
     */
    public static function get_catalog_filters(): Generator
    {
        // Now fetch the rest;
        $sql        = "SELECT `id`, `name` FROM `catalog_filter_group` ORDER BY `name` ";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            yield [
                'id' => (int) $row['id'],
                'name' => $row['name'],
            ];
        }
    }

    /**
     * get_name
     * Returns the name of the catalog matching the given ID
     */
    public static function getName(int $catalog_id): string
    {
        $sql        = "SELECT `name` FROM `catalog` WHERE `id` = ?";
        $db_results = Dba::read($sql, array($catalog_id));
        $row        = Dba::fetch_assoc($db_results);

        return $row['name'] ?? '';
    }

    /**
     * get_fullname
     */
    public function get_fullname(): ?string
    {
        if (!isset($this->f_name)) {
            $this->f_name = $this->name;
        }

        return $this->f_name;
    }

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

        return $this->link;
    }

    /**
     * Get item f_link.
     */
    public function get_f_link(): ?string
    {
        // don't do anything if it's formatted
        if (!isset($this->f_link)) {
            $this->f_link = '<a href="' . $this->get_link() . '" title="' . scrub_out($this->get_fullname()) . '">' . scrub_out($this->get_fullname()) . '</a>';
        }

        return $this->f_link;
    }

    /**
     * filter_user_count
     * Returns the number of users assigned to a particular filter.
     */
    public static function filter_user_count(int $filter_id): int
    {
        $sql        = "SELECT COUNT(1) AS `count` FROM `user` WHERE `catalog_filter_group` = ?";
        $db_results = Dba::read($sql, array($filter_id));
        $row        = Dba::fetch_assoc($db_results);

        return (int) $row['count'];
    }

    /**
     * filter_catalog_count
     * This returns the number of catalogs assigned to a filter.
     */
    public static function filter_catalog_count(int $filter_id): int
    {
        $sql        = "SELECT COUNT(1) AS `count` FROM `catalog_filter_group_map` WHERE `group_id` = ? AND `enabled` = 1";
        $db_results = Dba::read($sql, array($filter_id));
        $row        = Dba::fetch_assoc($db_results);

        return (int) $row['count'];
    }

    /**
     * filter_name_exists
     * can specifiy an ID to ignore in this check, useful for filter names.
     */
    public static function filter_name_exists(string $filter_name, int $exclude_id = 0): bool
    {
        $params = array($filter_name);
        $sql    = "SELECT `id` FROM `catalog_filter_group` WHERE `name` = ?";
        if ($exclude_id >= 0) {
            $sql .= " AND `id` != ?";
            $params[] = $exclude_id;
        }

        $db_results = Dba::read($sql, $params);
        if (Dba::num_rows($db_results) > 0) {
            return true;
        }

        return false;
    }

    /**
     * check_filter_catalog_enabled
     * Returns the `enabled` status of the filter/catalog combination
     */
    public static function check_filter_catalog_enabled(int $filter_id, int $catalog_id): bool
    {
        $sql        = "SELECT `enabled` FROM `catalog_filter_group_map` WHERE `group_id` = ? AND `catalog_id` = ? AND `enabled` = 1;";
        $db_results = Dba::read($sql, array($filter_id, $catalog_id));

        return Dba::num_rows($db_results) > 0;
    }

    /**
     * add_catalog_filter_group_map
     * Adds appropriate rows when a catalog is added.
     */
    public static function add_catalog_filter_group_map(int $catalog_id): void
    {
        $results    = array();
        $sql        = "SELECT `id` FROM `catalog_filter_group` ORDER BY `id`";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        foreach ($results as $filter_id) {
            $enabled = ($filter_id == 0) ? 1 : 0; // always enable for the DEFAULT group
            $sql     = "INSERT IGNORE INTO `catalog_filter_group_map` (`group_id`, `catalog_id`, `enabled`) VALUES (?, ?, ?);";
            $params  = array((int)$filter_id, $catalog_id, $enabled);
            Dba::write($sql, $params);
        }
    }

    /**
     * add_catalog_filter_group
     *
     * @param array<string, int> $catalogs
     *
     * @return PDOStatement|false
     */
    public static function add_catalog_filter_group(string $filter_name, array $catalogs)
    {
        // Create the filter
        Dba::write(
            'INSERT INTO `catalog_filter_group` (`name`) VALUES (?)',
            [$filter_name]
        );

        $filter_id = Dba::insert_id();

        // Fill in catalog_filter_group_map table for the new filter
        $results    = array();
        $sql        = "SELECT `id` FROM `catalog` ORDER BY `id`";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        $sql = "INSERT INTO `catalog_filter_group_map` (`group_id`, `catalog_id`, `enabled`) VALUES ";
        foreach ($results as $catalog_id) {
            $catalog_name = self::getName($catalog_id);
            $enabled      = $catalogs[$catalog_name];
            $sql .= "($filter_id, $catalog_id, $enabled),";
        }
        // Remove last comma to avoid SQL error
        $sql = substr($sql, 0, -1);

        return Dba::write($sql);
    }

    /**
     * edit_catalog_filter
     *
     * @param array<int, int> $catalogs
     */
    public static function edit_catalog_filter(int $filter_id, string $filter_name, array $catalogs): bool
    {
        // Modify the filter name
        $results = array();
        $sql     = "UPDATE `catalog_filter_group` SET `name` = ? WHERE `id` = ?;";
        Dba::write($sql, array($filter_name, $filter_id));

        // Fill in catalog_filter_group_map table for the filter
        $sql        = "SELECT `id` FROM `catalog` ORDER BY `id`";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        foreach ($results as $catalog_id) {
            $sql        = "SELECT `catalog_id` FROM `catalog_filter_group_map` WHERE `group_id` = ? AND `catalog_id` = ?";
            $db_results = Dba::read($sql, array($filter_id, $catalog_id));
            $enabled    = $catalogs[$catalog_id];
            $sql        = (Dba::num_rows($db_results))
                ? "UPDATE `catalog_filter_group_map` SET `enabled` = ? WHERE `group_id` = ? AND `catalog_id` = ?"
                : "INSERT INTO `catalog_filter_group_map` SET `enabled` = ?, `group_id` = ?, `catalog_id` = ?";
            if (!Dba::write($sql, array($enabled, $filter_id, $catalog_id))) {
                return false;
            }
        }
        self::garbage_collect_filters();

        return true;
    }

    /**
     * delete_catalog_filter
     * @return PDOStatement|false
     */
    public static function delete_catalog_filter(int $filter_id)
    {
        if ($filter_id > 0) {
            $params = array($filter_id);
            $sql    = "DELETE FROM `catalog_filter_group` WHERE `id` = ?";
            if (Dba::write($sql, $params)) {
                $sql = "DELETE FROM `catalog_filter_group_map` WHERE `group_id` = ?";

                return Dba::write($sql, $params);
            }
        }

        return false;
    }

    /**
     * reset_user_filter
     * reset a users's catalog filter to DEFAULT after deleting a filter group
     */
    public static function reset_user_filter(int $filter_id): void
    {
        $sql = "UPDATE `user` SET `catalog_filter_group` = 0 WHERE `catalog_filter_group` = ?";
        Dba::write($sql, array($filter_id));
    }

    /**
     * Check if a file is an audio.
     */
    public static function is_audio_file(string $file): bool
    {
        $ignore_pattern = AmpConfig::get('catalog_ignore_pattern');
        $ignore_check   = !($ignore_pattern) || preg_match("/(" . $ignore_pattern . ")/i", $file) === 0;
        $file_pattern   = AmpConfig::get('catalog_file_pattern');
        $pattern        = "/\.(" . $file_pattern . ")$/i";

        return ($ignore_check && preg_match($pattern, $file));
    }

    /**
     * Check if a file is a video.
     */
    public static function is_video_file(string $file): bool
    {
        $ignore_pattern = AmpConfig::get('catalog_ignore_pattern');
        $ignore_check   = !($ignore_pattern) || preg_match("/(" . $ignore_pattern . ")/i", $file) === 0;
        $video_pattern  = "/\.(" . AmpConfig::get('catalog_video_pattern') . ")$/i";

        return ($ignore_check && preg_match($video_pattern, $file));
    }

    /**
     * Check if a file is a playlist.
     */
    public static function is_playlist_file(string $file): bool
    {
        $ignore_pattern   = AmpConfig::get('catalog_ignore_pattern');
        $ignore_check     = !($ignore_pattern) || preg_match("/(" . $ignore_pattern . ")/i", $file) === 0;
        $playlist_pattern = "/\.(" . AmpConfig::get('catalog_playlist_pattern') . ")$/i";

        return ($ignore_check && preg_match($playlist_pattern, $file));
    }

    /**
     * Get catalog info from table.
     * @param int $object_id
     * @param string $table_name
     */
    public function get_info($object_id, $table_name = 'catalog'): array
    {
        $info = parent::get_info($object_id, $table_name);

        $table      = 'catalog_' . $this->get_type();
        $sql        = "SELECT `id` FROM `$table` WHERE `catalog_id` = ?";
        $db_results = Dba::read($sql, array($object_id));
        if ($results = Dba::fetch_assoc($db_results)) {
            $info_type = parent::get_info($results['id'], $table);
            foreach ($info_type as $key => $value) {
                if (!array_key_exists($key, $info) || !$info[$key]) {
                    $info[$key] = $value;
                }
            }
        }

        return $info;
    }

    /**
     * Get enable sql filter;
     * @param string $type
     * @param string $catalog_id
     */
    public static function get_enable_filter($type, $catalog_id): string
    {
        $sql = "";
        if ($type == "song" || $type == "album" || $type == "artist" || $type == "album_artist") {
            if ($type == "song") {
                $type = "id";
            }
            $sql = "(SELECT COUNT(`song_dis`.`id`) FROM `song` AS `song_dis` LEFT JOIN `catalog` AS `catalog_dis` ON `catalog_dis`.`id` = `song_dis`.`catalog` WHERE `song_dis`.`" . $type . "` = " . $catalog_id . " AND `catalog_dis`.`enabled` = '1' GROUP BY `song_dis`.`" . $type . "`) > 0";
        } elseif ($type == "album_disk") {
            $sql = "(SELECT DISTINCT COUNT(`album_disk`.`id`) FROM `album_disk` LEFT JOIN `album` AS `album_dis` ON `album_dis`.`id` = `album_disk`.`album_id` LEFT JOIN `catalog` AS `catalog_dis` ON `catalog_dis`.`id` = `album_dis`.`catalog` WHERE `album_dis`.`id` = " . $catalog_id . " AND `catalog_dis`.`enabled` = '1' GROUP BY `album_disk`.`id`) > 0";
        } elseif ($type == "video") {
            $sql = "(SELECT COUNT(`video_dis`.`id`) FROM `video` AS `video_dis` LEFT JOIN `catalog` AS `catalog_dis` ON `catalog_dis`.`id` = `video_dis`.`catalog` WHERE `video_dis`.`id` = " . $catalog_id . " AND `catalog_dis`.`enabled` = '1' GROUP BY `video_dis`.`id`) > 0";
        }

        return $sql;
    }

    /**
     * Get filter_user sql filter;
     * @param string $type
     * @param int $user_id
     */
    public static function get_user_filter($type, $user_id): string
    {
        switch ($type) {
            case "album":
            case "song":
            case "video":
            case "podcast":
            case "podcast_episode":
            case "live_stream":
                $sql = " `$type`.`catalog` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) ";
                break;
            case "artist":
                $sql = " `artist`.`id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "song_artist":
            case "song_album":
                $type = str_replace('song_', '', (string) $type);
                $sql  = " `song`.`$type` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "album_disk":
                $sql = " `$type`.`album_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'album_disk' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "album_artist":
                $sql = " `album`.`$type` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "label":
                $sql = " `label`.`id` IN (SELECT `label` FROM `label_asso` LEFT JOIN `artist` ON `label_asso`.`artist` = `artist`.`id` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'artist' AND `catalog_map`.`object_id` = `artist`.`id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'artist' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `label_asso`.`label`) ";
                break;
            case "playlist":
                $sql = " `playlist`.`id` IN (SELECT `playlist` FROM `playlist_data` LEFT JOIN `song` ON `playlist_data`.`object_id` = `song`.`id` AND `playlist_data`.`object_type` = 'song' LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'song' AND `catalog_map`.`object_id` = `song`.`id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'song' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `playlist_data`.`playlist`) ";
                break;
            case "share":
                $sql = " `share`.`object_id` IN (SELECT `share`.`object_id` FROM `share` LEFT JOIN `catalog_map` ON `share`.`object_type` = `catalog_map`.`object_type` AND `share`.`object_id` = `catalog_map`.`object_id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `share`.`object_id`, `share`.`object_type`) ";
                break;
            case "tag":
                $sql = " `tag`.`id` IN (SELECT `tag_id` FROM `tag_map` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = `tag_map`.`object_type` AND `catalog_map`.`object_id` = `tag_map`.`object_id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `tag_map`.`tag_id`) ";
                break;
            case 'tvshow':
                $sql = " `tvshow`.`id` IN (SELECT `tvshow` FROM `tvshow_season` LEFT JOIN `tvshow_episode` ON `tvshow_episode`.`season` = `tvshow_season`.`id` LEFT JOIN `video` ON `tvshow_episode`.`id` = `video`.`id` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'video' AND `catalog_map`.`object_id` = `video`.`id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `tvshow_season`.`tvshow`) ";
                break;
            case 'tvshow_season':
                $sql = " `tvshow_season`.`tvshow` IN (SELECT `season` FROM `tvshow_episode` LEFT JOIN `video` ON `tvshow_episode`.`id` = `video`.`id` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'video' AND `catalog_map`.`object_id` = `video`.`id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `tvshow_episode`.`season`) ";
                break;
            case 'tvshow_episode':
            case 'movie':
            case 'personal_video':
            case 'clip':
                $sql = " `$type`.`id` IN (SELECT `video`.`id` FROM `video` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'video' AND `catalog_map`.`object_id` = `video`.`id` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `video`.`id`) ";
                break;
            case "object_count_album_disk":
                // enum('album', 'album_disk', 'artist', 'catalog', 'tag', 'label', 'live_stream', 'playlist', 'podcast', 'podcast_episode', 'search', 'song', 'tvshow', 'tvshow_season', 'user', 'video')
                $sql = " `object_count`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'album_disk' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "object_count_artist":
            case "object_count_album":
            case "object_count_song":
            case "object_count_playlist":
            case "object_count_genre":
            case "object_count_catalog":
            case "object_count_live_stream":
            case "object_count_video":
            case "object_count_podcast":
            case "object_count_podcast_episode":
                $type = str_replace('object_count_', '', (string) $type);
                $sql  = " `object_count`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "rating_album_disk":
                // enum('album', 'album_disk', 'artist', 'catalog', 'tag', 'label', 'live_stream', 'playlist', 'podcast', 'podcast_episode', 'search', 'song', 'tvshow', 'tvshow_season', 'user', 'video')
                $sql = " `rating`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'album_disk' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "rating_artist":
            case "rating_album":
            case "rating_song":
            case "rating_stream":
            case "rating_live_stream":
            case "rating_video":
            case "rating_tvshow":
            case "rating_tvshow_season":
            case "rating_podcast":
            case "rating_podcast_episode":
                $type = str_replace('rating_', '', (string) $type);
                $sql  = " `rating`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "user_flag_album_disk":
                $sql = " `user_flag`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = 'album_disk' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "user_flag_artist":
            case "user_flag_album":
            case "user_flag_song":
            case "user_flag_video":
            case "user_flag_podcast_episode":
                $type = str_replace('user_flag_', '', (string) $type);
                $sql  = " `user_flag`.`object_id` IN (SELECT `catalog_map`.`object_id` FROM `catalog_map` LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog_map`.`object_type` = '$type' AND `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `catalog_map`.`object_id`) ";
                break;
            case "rating_playlist":
                $sql = " `rating`.`object_id` IN (SELECT DISTINCT(`playlist`.`id`) FROM `playlist` LEFT JOIN `playlist_data` ON `playlist_data`.`playlist` = `playlist`.`id` LEFT JOIN `catalog_map` ON `playlist_data`.`object_id` = `catalog_map`.`object_id` AND `playlist_data`.`object_type` = 'song' LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `playlist`.`id`) ";
                break;
            case "user_flag_playlist":
                $sql = " `user_flag`.`object_id` IN (SELECT DISTINCT(`playlist`.`id`) FROM `playlist` LEFT JOIN `playlist_data` ON `playlist_data`.`playlist` = `playlist`.`id` LEFT JOIN `catalog_map` ON `playlist_data`.`object_id` = `catalog_map`.`object_id` AND `playlist_data`.`object_type` = 'song' LEFT JOIN `catalog` ON `catalog_map`.`catalog_id` = `catalog`.`id` WHERE `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) GROUP BY `playlist`.`id`) ";
                break;
            case "catalog":
                $sql = " `catalog`.`id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` INNER JOIN `user` ON `user`.`catalog_filter_group` = `catalog_filter_group_map`.`group_id` WHERE `user`.`id` = $user_id AND `catalog_filter_group_map`.`enabled`=1) ";
                break;
            default:
                debug_event(__CLASS__, 'ERROR get_user_filter: ' . $type . ' not valid', 1);
                $sql = "";
        }

        return $sql;
    }

    /**
     * _create_filecache
     *
     * This populates an array which is used to speed up the add process.
     */
    protected function _create_filecache(): void
    {
        if (count($this->_filecache) == 0) {
            // Get _EVERYTHING_
            $sql        = 'SELECT `id`, `file` FROM `song` WHERE `catalog` = ?';
            $db_results = Dba::read($sql, array($this->id));

            // Populate the filecache
            while ($results = Dba::fetch_assoc($db_results)) {
                $this->_filecache[strtolower((string)$results['file'])] = $results['id'];
            }

            $sql        = 'SELECT `id`, `file` FROM `video` WHERE `catalog` = ?';
            $db_results = Dba::read($sql, array($this->id));

            while ($results = Dba::fetch_assoc($db_results)) {
                $this->_filecache[strtolower((string)$results['file'])] = 'v_' . $results['id'];
            }
        }
    }

    /**
     * get_update_info
     *
     * return the counts from user_data or update_info to speed up responses
     */
    public static function get_update_info(string $key, int $user_id): int
    {
        $sql = ($user_id > 0)
            ? "SELECT `key`, `value` FROM `user_data` WHERE `key` = ? AND `user` = " . $user_id
            : "SELECT `key`, `value` FROM `update_info` WHERE `key` = ?";
        $db_results = Dba::read($sql, array($key));
        $results    = Dba::fetch_assoc($db_results);

        return (int)($results['value'] ?? 0);
    }

    /**
     * set_update_info
     *
     * write the total_counts to update_info
     * @param string $key
     * @param int|float $value
     */
    public static function set_update_info($key, $value): void
    {
        Dba::write("REPLACE INTO `update_info` SET `key` = ?, `value` = ?;", array($key, $value));
    }

    /**
     * update_enabled
     * sets the enabled flag
     * @param bool $new_enabled
     * @param int $catalog_id
     * @return PDOStatement|bool
     */
    public static function update_enabled($new_enabled, $catalog_id)
    {
        /* Check them Rights! */
        if (!Access::check('interface', 75)) {
            return false;
        }

        return self::_update_item('enabled', ($new_enabled ? 1 : 0), $catalog_id);
    }

    /**
     * _update_item
     * This is a private function that should only be called from within the catalog class.
     * It takes a field, value, catalog id and level. first and foremost it checks the level
     * against Core::get_global('user') to make sure they are allowed to update this record
     * it then updates it and sets $this->{$field} to the new value
     * @param string $field
     * @param string|int $value
     * @param int $catalog_id
     * @return PDOStatement|bool
     */
    private static function _update_item($field, $value, $catalog_id)
    {
        /* Can't update to blank */
        if (!strlen(trim((string)$value))) {
            return false;
        }
        $sql = "UPDATE `catalog` SET `$field` = ? WHERE `id` = ?";

        return Dba::write($sql, array($value, $catalog_id));
    }

    /**
     * format
     *
     * This makes the object human-readable.
     */
    public function format(): void
    {
        $this->get_fullname();
        $this->get_link();
        $this->get_f_link();
        $this->f_update = $this->last_update ? get_datetime((int)$this->last_update) : T_('Never');
        $this->f_add    = $this->last_add ? get_datetime((int)$this->last_add) : T_('Never');
        $this->f_clean  = $this->last_clean ? get_datetime((int)$this->last_clean) : T_('Never');
    }

    /**
     * get_catalogs
     *
     * Pull all the current catalogs and return an array of ids of what you find
     * @param string $filter_type
     * @param int $user_id
     * @param bool $query
     * @return int[]
     *
     * @see CatalogLoader
     */
    public static function get_catalogs($filter_type = '', $user_id = null, $query = false): array
    {
        $params = array();
        $sql    = "SELECT `id` FROM `catalog` ";
        $join   = 'WHERE';
        if (!empty($filter_type)) {
            $sql .= "$join `gather_types` = ? ";
            $params[] = $filter_type;
            $join     = 'AND';
        }
        if (AmpConfig::get('catalog_disable')) {
            $sql .= "$join `enabled` = 1 ";
            $join = 'AND';
        }
        if (AmpConfig::get('catalog_filter')) {
            if ($user_id > 0) {
                $sql .= $join . self::get_user_filter('catalog', $user_id);
                $join = 'AND';
            }
            if ($user_id == -1) {
                $sql .= "$join `id` IN (SELECT `catalog_id` FROM `catalog_filter_group_map` WHERE `enabled` = 1 AND `group_id` = 0) ";
            }
        }
        $sql .= "ORDER BY `name`;";
        //debug_event(self::class, 'get_catalogs ' . $sql, 5);
        $db_results = Dba::read($sql, $params);
        $results    = array();
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }
        if (empty($results) && $query) {
            return array(0);
        }

        return $results;
    }

    /**
     * Run the cache_catalog_proc() on music catalogs.
     */
    public static function cache_catalogs(): void
    {
        $path   = (string)AmpConfig::get('cache_path', '');
        $target = (string)AmpConfig::get('cache_target', '');
        // need a destination and target filetype
        if (is_dir($path) && $target) {
            $catalogs = self::get_catalogs('music');
            foreach ($catalogs as $catalogid) {
                debug_event(__CLASS__, 'cache_catalogs: ' . $catalogid, 5);
                $catalog = self::create_from_id($catalogid);
                if ($catalog === null) {
                    break;
                }
                $catalog->cache_catalog_proc();
            }
            $catalog_dirs = new RecursiveDirectoryIterator($path);
            $dir_files    = new RecursiveIteratorIterator($catalog_dirs);
            $cache_files  = new RegexIterator($dir_files, "/\.$target/i");
            debug_event(__CLASS__, 'cache_catalogs: cleaning old files', 5);
            foreach ($cache_files as $file) {
                $path    = pathinfo($file);
                $song_id = $path['filename'];
                if (!Song::has_id($song_id)) {
                    unlink($file);
                    debug_event(__CLASS__, 'cache_catalogs: removed {' . $file . '}', 4);
                }
            }
        }
    }

    /**
     * Get last catalogs update.
     * @param int[]|null $catalogs
     */
    public static function getLastUpdate($catalogs = null): int
    {
        $last_update = 0;
        if ($catalogs == null || !is_array($catalogs)) {
            $catalogs = self::get_catalogs();
        }
        foreach ($catalogs as $catalogid) {
            $catalog = self::create_from_id($catalogid);
            if ($catalog === null) {
                break;
            }
            if ($catalog->last_add > $last_update) {
                $last_update = $catalog->last_add;
            }
            if ($catalog->last_update > $last_update) {
                $last_update = $catalog->last_update;
            }
            if ($catalog->last_clean > $last_update) {
                $last_update = $catalog->last_clean;
            }
        }

        return $last_update;
    }

    /**
     * get_stats
     *
     * This returns an hash with the #'s for the different
     * objects that are associated with this catalog. This is used
     * to build the stats box, it also calculates time.
     * @param int|null $catalog_id
     * @return array{
     *  tags: int,
     *  formatted_size: string,
     *  time_text: string,
     *  users: int,
     *  connected: int
     * }
     */
    public static function get_stats($catalog_id = 0): array
    {
        $counts         = ($catalog_id) ? self::count_catalog($catalog_id) : self::get_server_counts(0);
        $counts         = array_merge(self::getUserRepository()->getStatistics(), $counts);
        $counts['tags'] = ($catalog_id) ? 0 : self::count_tags();

        $counts['formatted_size'] = Ui::format_bytes($counts['size'], 2, 2);

        $hours = floor((int) $counts['time'] / 3600);
        $days  = (int)floor($hours / 24);
        $hours = $hours % 24;

        $time_text = "$days ";
        $time_text .= nT_('day', 'days', $days);
        $time_text .= ", $hours ";
        $time_text .= nT_('hour', 'hours', $hours);

        $counts['time_text'] = $time_text;

        return $counts;
    }

    /**
     * create
     *
     * This creates a new catalog entry and associate it to current instance
     * @param array $data
     */
    public static function create($data): int
    {
        $name           = $data['name'];
        $type           = $data['type'];
        $rename_pattern = $data['rename_pattern'];
        $sort_pattern   = $data['sort_pattern'];
        $gather_types   = $data['gather_media'];

        // Should it be an array? Not now.
        if (!in_array($gather_types, array('music', 'clip', 'tvshow', 'movie', 'personal_video', 'podcast'))) {
            return 0;
        }

        $insert_id = 0;
        $classname = self::CATALOG_TYPES[$type] ?? null;
        if ($classname === null) {
            return $insert_id;
        }

        $sql = 'INSERT INTO `catalog` (`name`, `catalog_type`, `rename_pattern`, `sort_pattern`, `gather_types`) VALUES (?, ?, ?, ?, ?)';
        Dba::write($sql, array(
            $name,
            $type,
            $rename_pattern,
            $sort_pattern,
            $gather_types
        ));

        $insert_id = Dba::insert_id();
        if (!$insert_id) {
            AmpError::add('general', T_('Failed to create the catalog, check the debug logs'));
            debug_event(__CLASS__, 'Insert failed: ' . json_encode($data), 2);

            return 0;
        }
        self::clear_catalog_cache();

        /** @var Catalog_beets|Catalog_beetsremote|Catalog_dropbox|Catalog_local|Catalog_remote|Catalog_Seafile|Catalog_subsonic $classname */
        if (!$classname::create_type($insert_id, $data)) {
            $sql = 'DELETE FROM `catalog` WHERE `id` = ?';
            Dba::write($sql, array($insert_id));
            $insert_id = 0;
        }

        return (int)$insert_id;
    }

    /**
     * clear_catalog_cache
     */
    public static function clear_catalog_cache(): void
    {
        // clear caches if enabled to allow getting the new object
        parent::remove_from_cache('user_catalog');
        parent::remove_from_cache('user_catalogmusic');
        if (AmpConfig::get('podcast')) {
            parent::remove_from_cache('user_catalogpodcast');
        }
        if (AmpConfig::get('video')) {
            parent::remove_from_cache('user_catalogclip');
            parent::remove_from_cache('user_catalogtvshow');
            parent::remove_from_cache('user_catalogmovie');
            parent::remove_from_cache('user_catalogpersonal_video');
        }
    }

    /**
     * count_tags
     *
     * This returns the current number of unique tags in the database.
     */
    public static function count_tags(): int
    {
        $sql        = "SELECT COUNT(`id`) FROM `tag` WHERE `is_hidden` = 0;";
        $db_results = Dba::read($sql);
        $row        = Dba::fetch_row($db_results);

        return $row[0] ?? 0;
    }

    /**
     * has_access
     *
     * When filtering catalogs you shouldn't be able to play the files
     * @param int|null $catalog_id
     * @param int $user_id
     */
    public static function has_access($catalog_id, $user_id): bool
    {
        if ($catalog_id === null || !AmpConfig::get('catalog_filter')) {
            return true;
        }
        if ($user_id == -1) {
            // DEFAULT group only for System / Guest access
            $params = array($catalog_id);
            $sql    = "SELECT `catalog_id` FROM `catalog_filter_group_map` WHERE `catalog_id` = ? AND `enabled` = 1 AND `group_id` = 0;";
        } else {
            $params = array($catalog_id, $user_id);
            $sql    = "SELECT `catalog_id` FROM `catalog_filter_group_map` WHERE `catalog_id` = ? AND `enabled` = 1 AND `group_id` IN (SELECT `catalog_filter_group` FROM `user` WHERE `id` = ?);";
        }
        //debug_event(self::class, 'has_access ' . $sql . ' ' . print_r($params, true), 5);

        $db_results = Dba::read($sql, $params);
        if (Dba::num_rows($db_results)) {
            return true;
        }

        return false;
    }

    /**
     * get_server_counts
     *
     * This returns the current number of songs, videos, albums, artists, items, etc across all catalogs on the server
     * @param int $user_id
     * @return int[]
     */
    public static function get_server_counts($user_id): array
    {
        $results = self::SERVER_COUNTS;
        if ($user_id > 0) {
            $sql        = "SELECT `key`, `value` FROM `user_data` WHERE `user` = ?;";
            $db_results = Dba::read($sql, array($user_id));
        } else {
            $sql        = "SELECT `key`, `value` FROM `update_info`;";
            $db_results = Dba::read($sql);
        }

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

        return $results;
    }

    /**
     * count_table
     *
     * Count and/or Update a table count when adding/removing from the server
     */
    public static function count_table(string $table, ?int $catalog_id = 0, ?int $update_time = 0): int
    {
        $sql       = "SELECT COUNT(`id`) FROM `$table` ";
        $params    = array();
        $where_sql = 'WHERE';
        if ($catalog_id > 0) {
            $sql .= $where_sql . " `catalog` = ? ";
            $params[]  = $catalog_id;
            $where_sql = 'AND';
        }
        if ($update_time > 0) {
            $sql .= $where_sql . " `update_time` <= ? ";
            $params[] = $update_time;
        }
        $sql = rtrim($sql, ';');
        //debug_event(self::class, 'count_table ' . $sql . ' ' . print_r($params, true), 5);
        $db_results = Dba::read($sql, $params);
        $row        = Dba::fetch_row($db_results);
        if (empty($row)) {
            return 0;
        }
        if ($catalog_id === 0) {
            self::set_update_info($table, (int)$row[0]);
        }

        return (int)$row[0];
    }

    /**
     * count_catalog
     *
     * This returns the current number of songs, videos, podcast_episodes in this catalog.
     * @param int $catalog_id
     * @return int[]
     */
    public static function count_catalog($catalog_id): array
    {
        $catalog = self::create_from_id($catalog_id);
        $results = array(
            'items' => 0,
            'time' => 0,
            'size' => 0
        );
        if ($catalog instanceof Catalog) {
            $where_sql = $catalog_id ? 'WHERE `catalog` = ?' : '';
            $params    = $catalog_id ? array($catalog_id) : array();

            $table = self::get_table_from_type($catalog->gather_types);
            if ($table == 'podcast_episode' && $catalog_id) {
                $where_sql = "WHERE `podcast` IN (SELECT `id` FROM `podcast` WHERE `catalog` = ?)";
            }
            $sql              = "SELECT COUNT(`id`) AS `items`, IFNULL(SUM(`time`), 0) AS `time`, IFNULL(SUM(`size`)/1024/1024, 0) AS `size` FROM `" . $table . "` " . $where_sql;
            $db_results       = Dba::read($sql, $params);
            $row              = Dba::fetch_assoc($db_results);
            $results['items'] = (int)($row['items'] ?? 0);
            $results['time']  = (int)($row['time'] ?? 0);
            $results['size']  = (int)($row['size'] ?? 0);
        }

        return $results;
    }

    /**
     * get_uploads_sql
     *
     * @param string $type
     * @param int $user_id
     */
    public static function get_uploads_sql($type, $user_id = 0): string
    {
        $sql    = '';
        $column = ($type == 'song')
            ? 'user_upload'
            : 'user';
        $table = ($type == 'album')
            ? 'artist'
            : $type;
        $where_sql = ($user_id > 0)
            ? "WHERE `$table`.`$column` = '" . $user_id . "'"
            : "WHERE `$table`.`$column` IS NOT NULL";
        switch ($type) {
            case 'song':
                $sql = "SELECT `song`.`id` AS `id` FROM `song` $where_sql";
                break;
            case 'album':
                $sql = "SELECT DISTINCT `album`.`id` AS `id` FROM `album` LEFT JOIN `artist` on `album`.`album_artist` = `artist`.`id` $where_sql";
                break;
            case 'artist':
                $sql = "SELECT DISTINCT `id` FROM `artist` $where_sql";
                break;
        }
        //debug_event(self::class, 'get_uploads_sql ' . $sql, 5);

        return $sql;
    }

    /**
     * get_album_ids
     *
     * This returns an array of ids of albums that have songs in this
     * catalog's
     * @param string $filter
     * @return int[]
     */
    public function get_album_ids($filter = ''): array
    {
        $results = array();

        $sql = 'SELECT `album`.`id` FROM `album` WHERE `album`.`catalog` = ?';
        if ($filter === 'art') {
            $sql = "SELECT `album`.`id` FROM `album` LEFT JOIN `image` ON `album`.`id` = `image`.`object_id` AND `object_type` = 'album' WHERE `album`.`catalog` = ? AND `image`.`object_id` IS NULL";
        }
        $db_results = Dba::read($sql, array($this->id));

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

        return array_reverse($results);
    }

    /**
     * get_video_ids
     *
     * This returns an array of ids of videos in this catalog
     * @param string $type
     * @return int[]
     */
    public function get_video_ids($type = ''): array
    {
        $results = array();

        $sql = 'SELECT DISTINCT(`video`.`id`) AS `id` FROM `video` ';
        if (!empty($type)) {
            $sql .= 'JOIN `' . $type . '` ON `' . $type . '`.`id` = `video`.`id`';
        }
        $sql .= 'WHERE `video`.`catalog` = ?';
        $db_results = Dba::read($sql, array($this->id));

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

        return $results;
    }

    /**
     *
     * @param int[]|null $catalogs
     * @param string $type
     * @return Video[]
     */
    public static function get_videos($catalogs = null, $type = ''): array
    {
        if (!$catalogs) {
            $catalogs = self::get_catalogs();
        }

        $results = array();
        foreach ($catalogs as $catalog_id) {
            $catalog = self::create_from_id($catalog_id);
            if ($catalog === null) {
                break;
            }
            $video_ids = $catalog->get_video_ids($type);
            foreach ($video_ids as $video_id) {
                $results[] = Video::create_from_id($video_id);
            }
        }

        return $results;
    }

    /**
     *
     * @param int|null $catalog_id
     * @param string $type
     */
    public static function get_videos_count($catalog_id = 0, $type = ''): int
    {
        $sql = "SELECT COUNT(`video`.`id`) AS `video_cnt` FROM `video` ";
        if (!empty($type)) {
            $sql .= "JOIN `" . $type . "` ON `" . $type . "`.`id` = `video`.`id` ";
        }
        if ($catalog_id) {
            $sql .= "WHERE `video`.`catalog` = `" . (string)($catalog_id) . "`";
        }
        $db_results = Dba::read($sql);
        $row        = Dba::fetch_row($db_results);

        return $row[0] ?? 0;
    }

    /**
     * get_tvshow_ids
     *
     * This returns an array of ids of tvshows in this catalog
     * @return int[]
     */
    public function get_tvshow_ids(): array
    {
        $results    = array();
        $sql        = 'SELECT DISTINCT(`tvshow`.`id`) AS `id` FROM `tvshow` JOIN `tvshow_season` ON `tvshow_season`.`tvshow` = `tvshow`.`id` JOIN `tvshow_episode` ON `tvshow_episode`.`season` = `tvshow_season`.`id` JOIN `video` ON `video`.`id` = `tvshow_episode`.`id` WHERE `video`.`catalog` = ?';
        $db_results = Dba::read($sql, array($this->id));
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        return $results;
    }

    /**
     * get_tvshows
     * @param int[]|null $catalogs
     * @return TvShow[]
     */
    public static function get_tvshows($catalogs = null): array
    {
        if (!$catalogs) {
            $catalogs = self::get_catalogs();
        }

        $results = array();
        foreach ($catalogs as $catalog_id) {
            $catalog = self::create_from_id($catalog_id);
            if ($catalog === null) {
                break;
            }
            $tvshow_ids = $catalog->get_tvshow_ids();
            foreach ($tvshow_ids as $tvshow_id) {
                $results[] = new TvShow($tvshow_id);
            }
        }

        return $results;
    }

    /**
     * get_name_array
     *
     * Get each array of fullname's for a object type
     * @param array $objects
     * @param string $table
     * @return array
     */
    public static function get_name_array($objects, $table): array
    {
        switch ($table) {
            case 'album':
            case 'artist':
                $sql = "SELECT DISTINCT `$table`.`id`, LTRIM(CONCAT(COALESCE(`$table`.`prefix`, ''), ' ', `$table`.`name`)) AS `name` FROM `$table` WHERE `id` IN (" . implode(",", $objects) . ");";
                break;
            case 'catalog':
            case 'live_stream':
            case 'playlist':
            case 'search':
                $sql = "SELECT DISTINCT `$table`.`id`, `$table`.`name` AS `name` FROM `$table` WHERE `id` IN (" . implode(",", $objects) . ");";
                break;
            case 'podcast':
            case 'podcast_episode':
            case 'song':
            case 'video':
                $sql = "SELECT DISTINCT `$table`.`id`, `$table`.`title` AS `name` FROM `$table` WHERE `id` IN (" . implode(",", $objects) . ");";
                break;
            case 'share':
                $sql = "SELECT DISTINCT `$table`.`id`, `$table`.`description` AS `name` FROM `$table` WHERE `id` IN (" . implode(",", $objects) . ");";
                break;
            case 'playlist_search':
                $empty_playlist = empty($objects['playlist']);
                $empty_search   = empty($objects['search']);
                if (!$empty_playlist && !$empty_search) {
                    $sql = "SELECT DISTINCT `playlist`.`id`, `playlist`.`name` AS `name` FROM `playlist` WHERE `id` IN (" . implode(",", $objects['playlist']) . ") UNION SELECT DISTINCT CONCAT('smart_', `search`.`id`) AS `id`, `search`.`name` FROM `search` WHERE CONCAT('smart_', `search`.`id`) IN ('" . implode("','", $objects['search']) . "');";
                } elseif ($empty_playlist && !$empty_search) {
                    $sql = "SELECT DISTINCT CONCAT('smart_', `search`.`id`) AS `id`, `search`.`name` FROM `search` WHERE CONCAT('smart_', `search`.`id`) IN ('" . implode("','", $objects['search']) . "');";
                } elseif ($empty_search && !$empty_playlist) {
                    $sql = "SELECT DISTINCT `playlist`.`id`, `playlist`.`name` AS `name` FROM `playlist` WHERE `id` IN (" . implode(",", $objects) . ");";
                } else {
                    return array();
                }
                break;
            default:
                return array();
        }

        $db_results = Dba::read($sql);
        $results    = array();
        while ($row = Dba::fetch_assoc($db_results, false)) {
            $results[] = $row;
        }

        return $results;
    }

    /**
     * get_artist_arrays
     *
     * Get each array of [id, f_name, name, album_count, catalog_id, has_art] for artists in an array of catalog id's
     * @param array $catalogs
     * @return array
     */
    public static function get_artist_arrays($catalogs): array
    {
        $sql = (count($catalogs) == 1)
            ? "SELECT DISTINCT `artist`.`id`, LTRIM(CONCAT(COALESCE(`artist`.`prefix`, ''), ' ', `artist`.`name`)) AS `f_name`, `artist`.`name`, `artist`.`album_count` AS `album_count`, `catalog_map`.`catalog_id` AS `catalog_id`, `image`.`object_id` AS `has_art` FROM `artist` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'artist' AND `catalog_map`.`object_id` = `artist`.`id` AND `catalog_map`.`catalog_id` = " . (int)$catalogs[0] . " LEFT JOIN `image` ON `image`.`object_type` = 'artist' AND `image`.`object_id` = `artist`.`id` AND `image`.`size` = 'original' WHERE `catalog_map`.`catalog_id` IS NOT NULL ORDER BY `f_name`;"
            : "SELECT DISTINCT `artist`.`id`, LTRIM(CONCAT(COALESCE(`artist`.`prefix`, ''), ' ', `artist`.`name`)) AS `f_name`, `artist`.`name`, `artist`.`album_count` AS `album_count`, MIN(`catalog_map`.`catalog_id`) AS `catalog_id`, `image`.`object_id` AS `has_art` FROM `artist` LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'artist' AND `catalog_map`.`object_id` = `artist`.`id` AND `catalog_map`.`catalog_id` IN (" . Dba::escape(implode(',', $catalogs)) . ") LEFT JOIN `image` ON `image`.`object_type` = 'artist' AND `image`.`object_id` = `artist`.`id` AND `image`.`size` = 'original' WHERE `catalog_map`.`catalog_id` IS NOT NULL GROUP BY `artist`.`id`, `f_name`, `artist`.`name`, `artist`.`album_count`, `image`.`object_id` ORDER BY `f_name`;";

        $db_results = Dba::read($sql);
        $results    = array();
        while ($row = Dba::fetch_assoc($db_results, false)) {
            $results[] = $row;
        }

        return $results;
    }

    /**
     * get_artist_ids
     *
     * This returns an array of ids of artist that have songs in this catalog
     * @param string $filter
     * @return int[]
     */
    public function get_artist_ids($filter = ''): array
    {
        $results = array();

        $sql = 'SELECT DISTINCT(`song`.`artist`) AS `artist` FROM `song` WHERE `song`.`catalog` = ?';
        if ($filter === 'art') {
            $sql = "SELECT DISTINCT(`song`.`artist`) AS `artist` FROM `song` LEFT JOIN `image` ON `song`.`artist` = `image`.`object_id` AND `object_type` = 'artist' WHERE `song`.`catalog` = ? AND `image`.`object_id` IS NULL";
        }
        if ($filter === 'info') {
            // used for recommendations / similar artists
            $sql = "SELECT DISTINCT(`artist`.`id`) AS `artist` FROM `artist` WHERE `artist`.`id` NOT IN (SELECT `object_id` FROM `recommendation` WHERE `object_type` = 'artist') ORDER BY RAND() LIMIT 500;";
        }
        if ($filter === 'time') {
            // used checking musicbrainz and other plugins
            $sql = "SELECT DISTINCT(`artist`.`id`) AS `artist` FROM `artist` WHERE (`artist`.`last_update` < (UNIX_TIMESTAMP() - 2629800) AND `artist`.`mbid` LIKE '%-%-%-%-%') ORDER BY RAND();";
        }
        if ($filter === 'count') {
            // Update for things added in the last run or empty ones
            $sql = "SELECT DISTINCT(`artist`.`id`) AS `artist` FROM `artist` WHERE `artist`.`id` IN (SELECT DISTINCT `song`.`artist` FROM `song` WHERE `song`.`catalog` = ? AND `addition_time` > " . $this->last_add . ") OR (`album_count` = 0 AND `song_count` = 0) ";
        }
        $db_results = Dba::read($sql, array($this->id));

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

        return array_reverse($results);
    }

    /**
     * get_artists
     *
     * This returns an array of artists that have songs in the catalogs parameter
     * @param array|null $catalogs
     * @param int $size
     * @param int $offset
     * @return Artist[]
     */
    public static function get_artists($catalogs = null, $size = 0, $offset = 0): array
    {
        $sql_where = "WHERE `artist`.`album_count` > 0";
        if (is_array($catalogs) && count($catalogs)) {
            $catlist   = '(' . implode(',', $catalogs) . ')';
            $sql_where = " AND `song`.`catalog` IN $catlist";
        }

        $sql_limit = "";
        if ($offset > 0 && $size > 0) {
            $sql_limit = "LIMIT " . $offset . ", " . $size;
        } elseif ($size > 0) {
            $sql_limit = "LIMIT " . $size;
        } elseif ($offset > 0) {
            // MySQL doesn't have notation for last row, so we have to use the largest possible BIGINT value
            // https://dev.mysql.com/doc/refman/5.0/en/select.html  // TODO mysql8 test
            $sql_limit = "LIMIT " . $offset . ", 18446744073709551615";
        }
        $sql        = "SELECT `artist`.`id`, `artist`.`name`, `artist`.`prefix`, `artist`.`summary`, `artist`.`album_count` AS `albums` FROM `song` LEFT JOIN `artist` ON `artist`.`id` = `song`.`artist` $sql_where GROUP BY `artist`.`id`, `artist`.`name`, `artist`.`prefix`, `artist`.`summary`, `song`.`artist`, `artist`.`album_count` ORDER BY `artist`.`name` " . $sql_limit;
        $db_results = Dba::read($sql);
        $results    = array();
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = Artist::construct_from_array($row);
        }

        return $results;
    }

    /**
     * get_id_from_file
     *
     * Get media id from the file path.
     *
     * @param string $file_path
     * @param string $media_type
     */
    public static function get_id_from_file($file_path, $media_type): int
    {
        $sql        = "SELECT `id` FROM `$media_type` WHERE `file` = ?;";
        $db_results = Dba::read($sql, array($file_path));

        if ($results = Dba::fetch_assoc($db_results)) {
            return (int)$results['id'];
        }

        return 0;
    }

    /**
     * get_ids_from_folder
     *
     * Get media id's from a base folder path
     *
     * @param string $folder_path
     * @param string $media_type
     * @return int[]
     */
    public static function get_ids_from_folder($folder_path, $media_type): array
    {
        $objects     = array();
        $folder_path = Dba::escape($folder_path);
        $media_type  = Dba::escape($media_type);
        $sql         = "SELECT `id` FROM `$media_type` WHERE `file` LIKE '$folder_path%'";
        $db_results  = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            $objects[] = (int)$row['id'];
        }

        return $objects;
    }

    /**
     * get_label_ids
     *
     * This returns an array of ids of labels
     * @param string $filter
     * @return int[]
     */
    public function get_label_ids($filter): array
    {
        $results = array();

        $sql        = 'SELECT `id` FROM `label` WHERE `category` = ? OR `mbid` IS NULL';
        $db_results = Dba::read($sql, array($filter));

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

        return $results;
    }

    /**
     * get all artists or artist children of a catalog id (Used for WebDav)
     * @param string $name
     * @param int $catalog_id
     * @return array
     */
    public static function get_children($name, $catalog_id = 0): array
    {
        $childrens = array();
        $sql       = "SELECT DISTINCT `artist`.`id` FROM `artist` ";
        if ((int)$catalog_id > 0) {
            $sql .= "LEFT JOIN `catalog_map` ON `catalog_map`.`object_type` = 'album_artist' AND `catalog_map`.`object_id` = `artist`.`id` AND `catalog_map`.`catalog_id` = " . (int)$catalog_id;
        }
        $sql .= "WHERE (`artist`.`name` = ? OR LTRIM(CONCAT(COALESCE(`artist`.`prefix`, ''), ' ', `artist`.`name`)) = ?) ";
        if ((int)$catalog_id > 0) {
            $sql .= "AND `catalog_map`.`object_id` IS NOT NULL";
        }
        $db_results = Dba::read($sql, array($name, $name));
        while ($row = Dba::fetch_assoc($db_results)) {
            $childrens[] = array(
                'object_type' => 'artist',
                'object_id' => $row['id']
            );
        }

        return $childrens;
    }

    /**
     * get_albums
     *
     * Returns an array of ids of albums that have songs in the catalogs parameter
     * @param int $size
     * @param int $offset
     * @param int[]|null $catalogs
     * @return int[]
     */
    public static function get_albums($size = 0, $offset = 0, $catalogs = null): array
    {
        $sql = "SELECT `album`.`id` FROM `album` ";
        if (is_array($catalogs) && count($catalogs)) {
            $catlist = '(' . implode(',', $catalogs) . ')';
            $sql     = "SELECT `album`.`id` FROM `song` LEFT JOIN `album` ON `album`.`id` = `song`.`album` WHERE `song`.`catalog` IN $catlist ";
        }

        $sql_limit = "";
        if ($offset > 0 && $size > 0) {
            $sql_limit = "LIMIT $offset, $size";
        } elseif ($size > 0) {
            $sql_limit = "LIMIT $size";
        } elseif ($offset > 0) {
            // MySQL doesn't have notation for last row, so we have to use the largest possible BIGINT value
            // https://dev.mysql.com/doc/refman/5.0/en/select.html
            $sql_limit = "LIMIT $offset, 18446744073709551615";
        }

        $sql .= "GROUP BY `album`.`id` ORDER BY `album`.`name` $sql_limit";

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

        return $results;
    }

    /**
     * get_albums_by_artist
     *
     * Returns an array of ids of albums that have songs in the catalogs parameter, grouped by artist
     * @param int $size
     * @param int $offset
     * @param int[]|null $catalogs
     * @return int[]
     * @oaram int $offset
     */
    public static function get_albums_by_artist($size = 0, $offset = 0, $catalogs = null): array
    {
        $sql       = "SELECT `album`.`id` FROM `album` ";
        $sql_where = "";
        $sql_group = "GROUP BY `album`.`id`, `artist`.`name`, `artist`.`id`, `album`.`name`, `album`.`mbid`";
        if (is_array($catalogs) && count($catalogs)) {
            $catlist   = '(' . implode(',', $catalogs) . ')';
            $sql       = "SELECT `song`.`album` as 'id' FROM `song` LEFT JOIN `album` ON `album`.`id` = `song`.`album` ";
            $sql_where = "WHERE `song`.`catalog` IN $catlist";
            $sql_group = "GROUP BY `song`.`album`, `artist`.`name`, `artist`.`id`, `album`.`name`, `album`.`mbid`";
        }

        $sql_limit = "";
        if ($offset > 0 && $size > 0) {
            $sql_limit = "LIMIT $offset, $size";
        } elseif ($size > 0) {
            $sql_limit = "LIMIT $size";
        } elseif ($offset > 0) {
            // MySQL doesn't have notation for last row, so we have to use the largest possible BIGINT value
            // https://dev.mysql.com/doc/refman/5.0/en/select.html  // TODO mysql8 test
            $sql_limit = "LIMIT $offset, 18446744073709551615";
        }

        $sql .= "LEFT JOIN `artist` ON `artist`.`id` = `album`.`album_artist` $sql_where $sql_group ORDER BY `artist`.`name`, `artist`.`id`, `album`.`name` $sql_limit";

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

        return $results;
    }

    /**
     * get_podcast_ids
     *
     * This returns an array of ids of podcasts in this catalog
     * @return int[]
     */
    public function get_podcast_ids(): array
    {
        $results = array();

        $sql        = 'SELECT `podcast`.`id` FROM `podcast` WHERE `podcast`.`catalog` = ?';
        $db_results = Dba::read($sql, array($this->id));
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        return $results;
    }

    /**
     *
     * @param int[]|null $catalogs
     * @return Podcast[]
     */
    public static function get_podcasts($catalogs = null): array
    {
        if (!$catalogs) {
            $catalogs = self::get_catalogs('podcast');
        }

        $podcastRepository = self::getPodcastRepository();

        $results = array();
        foreach ($catalogs as $catalog_id) {
            $catalog = self::create_from_id($catalog_id);
            if ($catalog === null) {
                break;
            }
            $podcast_ids = $catalog->get_podcast_ids();
            foreach ($podcast_ids as $podcast_id) {
                $podcast = $podcastRepository->findById($podcast_id);
                if ($podcast !== null) {
                    $results[] = $podcast;
                }
            }
        }

        return $results;
    }

    /**
     * get_newest_podcasts_ids
     *
     * This returns an array of ids of latest podcast episodes in this catalog
     * @param int $count
     * @return list<int>
     */
    private function get_newest_podcasts_ids(int $count): array
    {
        $results = array();

        $sql = 'SELECT `podcast_episode`.`id` FROM `podcast_episode` INNER JOIN `podcast` ON `podcast`.`id` = `podcast_episode`.`podcast` WHERE `podcast`.`catalog` = ? ORDER BY `podcast_episode`.`pubdate` DESC';
        if ($count > 0) {
            $sql .= ' LIMIT ' . (string)$count;
        }
        $db_results = Dba::read($sql, array($this->id));
        while ($row = Dba::fetch_assoc($db_results)) {
            $results[] = (int)$row['id'];
        }

        return $results;
    }

    /**
     *
     * @param int $count
     * @return Podcast_Episode[]
     */
    public static function get_newest_podcasts($count): array
    {
        $catalogs = self::get_catalogs('podcast');
        $results  = array();

        foreach ($catalogs as $catalog_id) {
            $catalog = self::create_from_id($catalog_id);
            if ($catalog === null) {
                break;
            }
            $episode_ids = $catalog->get_newest_podcasts_ids($count);
            foreach ($episode_ids as $episode_id) {
                $results[] = new Podcast_Episode($episode_id);
            }
        }

        return $results;
    }

    /**
     * gather_art_item
     * @param string $type
     * @param int $object_id
     * @param bool $db_art_first
     * @param bool $api
     */
    public static function gather_art_item($type, $object_id, $db_art_first = false, $api = false): bool
    {
        // Should be more generic !
        if ($type == 'video') {
            $libitem = Video::create_from_id($object_id);
        } else {
            $className = ObjectTypeToClassNameMapper::map($type);
            /** @var library_item $libitem */
            $libitem = new $className($object_id);
        }
        $inserted = false;
        $options  = array();
        if (method_exists($libitem, 'format')) {
            $libitem->format();
        }
        if ($libitem->getId() > 0) {
            // Only search on items with default art kind AS `default`.
            if ($libitem->get_default_art_kind() == 'default') {
                $keywords = $libitem->get_keywords();
                $keyword  = '';
                foreach ($keywords as $key => $word) {
                    $options[$key] = $word['value'];
                    if (array_key_exists('important', $word) && !empty($word['value'])) {
                        $keyword .= ' ' . $word['value'];
                    }
                }
                $options['keyword'] = $keyword;
            }

            $parent = $libitem->get_parent();
            if (!empty($parent) && $type !== 'album') {
                self::gather_art_item($parent['object_type'], $parent['object_id'], $db_art_first, $api);
            }
        }

        $art = new Art($object_id, $type);
        // don't search for art when you already have it
        if ($art->has_db_info() && $db_art_first) {
            debug_event(__CLASS__, "gather_art_item $type: {{$object_id}} blocked", 5);
            $results = array();
        } else {
            debug_event(__CLASS__, "gather_art_item $type: {{$object_id}} searching", 4);

            global $dic;
            $results = $dic->get(ArtCollectorInterface::class)->collect(
                $art,
                $options
            );
        }

        foreach ($results as $result) {
            // Pull the string representation from the source
            $image = Art::get_from_source($result, $type);
            if (strlen((string)$image) > '5') {
                $inserted = $art->insert($image, $result['mime']);
                // If they've enabled resizing of images generate a thumbnail
                if (AmpConfig::get('resize_images')) {
                    $size  = array('width' => 275, 'height' => 275);
                    $thumb = $art->generate_thumb($image, $size, $result['mime']);
                    if (!empty($thumb)) {
                        $art->save_thumb($thumb['thumb'], $thumb['thumb_mime'], $size);
                    }
                }
                if ($inserted) {
                    break;
                }
            } elseif ($result === true) {
                debug_event(__CLASS__, 'Database already has image.', 3);
            } else {
                debug_event(__CLASS__, 'Image less than 5 chars, not inserting', 3);
            }
        }

        if ($type == 'video' && AmpConfig::get('generate_video_preview')) {
            Video::generate_preview($object_id);
        }

        if (Ui::check_ticker() && !$api) {
            Ui::update_text('read_art_' . $object_id, $libitem->get_fullname());
        }
        if ($inserted) {
            return true;
        }

        return false;
    }

    /**
     * gather_art
     *
     * This runs through all of the albums and finds art for them
     * This runs through all of the needs art albums and tries
     * to find the art for them from the mp3s
     * @param int[]|null $songs
     * @param int[]|null $videos
     */
    public function gather_art($songs = null, $videos = null): bool
    {
        // Make sure they've actually got methods
        $art_order       = AmpConfig::get('art_order');
        $gather_song_art = AmpConfig::get('gather_song_art', false);
        $db_art_first    = ($art_order[0] == 'db');
        if (!count($art_order)) {
            debug_event(__CLASS__, 'art_order not set, self::gather_art aborting', 3);

            return false;
        }

        // Prevent the script from timing out
        set_time_limit(0);

        $search_count = 0;
        $searches     = array();
        if ($songs == null) {
            $searches['album']  = $this->get_album_ids('art');
            $searches['artist'] = $this->get_artist_ids('art');
            if ($gather_song_art) {
                $searches['song'] = $this->get_song_ids();
            }
        } else {
            $searches['album']  = array();
            $searches['artist'] = array();
            if ($gather_song_art) {
                $searches['song'] = array();
            }
            foreach ($songs as $song_id) {
                $song = new Song($song_id);
                if ($song->isNew() === false) {
                    if (!in_array($song->album, $searches['album'])) {
                        $searches['album'][] = $song->album;
                    }
                    if (!in_array($song->artist, $searches['artist'])) {
                        $searches['artist'][] = $song->artist;
                    }
                    if ($gather_song_art) {
                        $searches['song'][] = $song->id;
                    }
                }
            }
        }
        if ($videos == null) {
            $searches['video'] = $this->get_video_ids();
        } else {
            $searches['video'] = $videos;
        }

        debug_event(__CLASS__, 'gather_art found ' . (string) count($searches) . ' items missing art', 4);
        // Run through items and get the art!
        foreach ($searches as $key => $values) {
            foreach ($values as $object_id) {
                self::gather_art_item($key, (int)$object_id, $db_art_first);

                // Stupid little cutesie thing
                $search_count++;
                if (Ui::check_ticker()) {
                    Ui::update_text('count_art_' . $this->id, $search_count);
                }
            }
        }
        // One last time for good measure
        Ui::update_text('count_art_' . $this->id, $search_count);

        return true;
    }

    /**
     * gather_artist_info
     *
     * This runs through all of the artists and refreshes last.fm information
     * including similar artists that exist in your catalog.
     * @param array $artist_list
     */
    public function gather_artist_info($artist_list = array()): void
    {
        // Prevent the script from timing out
        set_time_limit(0);

        $search_count = 0;
        debug_event(__CLASS__, 'gather_artist_info found ' . (string) count($artist_list) . ' items to check', 4);
        // Run through items and refresh info
        foreach ($artist_list as $object_id) {
            Recommendation::get_artist_info($object_id);
            Recommendation::get_artists_like($object_id);
            Artist::set_last_update($object_id);
            // get similar songs too
            $artistSongs = static::getSongRepository()->getAllByArtist($object_id);
            foreach ($artistSongs as $song_id) {
                Recommendation::get_songs_like($song_id);
            }

            // Stupid little cutesie thing
            $search_count++;
            if (Ui::check_ticker()) {
                Ui::update_text('count_artist_' . $object_id, $search_count);
            }
        }

        // One last time for good measure
        Ui::update_text('count_artist_complete', $search_count);
    }

    /**
     * update_from_external
     *
     * This runs through all of the labels and refreshes information from musicbrainz
     * @param array $object_list
     * @param string $object_type
     */
    public function update_from_external($object_list, $object_type): void
    {
        // Prevent the script from timing out
        set_time_limit(0);

        debug_event(__CLASS__, 'update_from_external found ' . (string) count($object_list) . ' ' . $object_type . '\'s to check', 4);

        // only allow your primary external metadata source to update values
        $overwrites  = true;
        $meta_order  = array_map('strtolower', static::getConfigContainer()->get(ConfigurationKeyEnum::METADATA_ORDER));
        $plugin_list = Plugin::get_plugins('get_external_metadata');
        $user        = (!empty(Core::get_global('user')))
            ? Core::get_global('user')
            : new User(-1);

        $labelRepository = self::getLabelRepository();

        foreach ($meta_order as $plugin_name) {
            if (in_array($plugin_name, $plugin_list)) {
                // only load metadata plugins you enable
                $plugin = new Plugin($plugin_name);
                if ($plugin->_plugin !== null && $plugin->load($user) && $overwrites) {
                    debug_event(__CLASS__, "get_external_metadata with: " . $plugin_name, 3);
                    // Run through items and refresh info
                    switch ($object_type) {
                        case 'label':
                            foreach ($object_list as $label_id) {
                                $label = $labelRepository->findById($label_id);
                                if ($label !== null) {
                                    $plugin->_plugin->get_external_metadata($label, 'label');
                                }
                            }
                            break;
                        case 'artist':
                            foreach ($object_list as $artist_id) {
                                $artist = new Artist($artist_id);
                                $plugin->_plugin->get_external_metadata($artist, 'artist');
                            }
                            $overwrites = false;
                            break;
                        default:
                    }
                }
            }
        }
    }

    /**
     * get_songs
     *
     * Returns an array of song objects.
     * @return Song[]
     */
    public function get_songs($offset = 0, $limit = 0): array
    {
        $songs   = array();
        $results = array();
        if ($offset > 0) {
            $limit = $offset . ', ' . $limit;
        }

        $sql = "SELECT `id` FROM `song` WHERE `catalog` = ? AND `enabled` = '1' ORDER BY `album`";
        if ($offset > 0 || $limit > 0) {
            $sql .= " LIMIT $limit";
        }
        $db_results = Dba::read($sql, array($this->id));

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

        if (AmpConfig::get('memory_cache')) {
            Song::build_cache($songs);
        }

        foreach ($songs as $song_id) {
            $results[] = new Song($song_id);
        }

        return $results;
    }

    /**
     * get_song_ids
     *
     * Returns an array of song ids.
     * @return int[]
     */
    public function get_song_ids(): array
    {
        $songs = array();

        $sql        = "SELECT `id` FROM `song` WHERE `catalog` = ? AND `enabled` = '1'";
        $db_results = Dba::read($sql, array($this->id));

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

        return $songs;
    }

    /**
     * update_last_update
     * updates the last_update of the catalog
     */
    protected function update_last_update(int $date): void
    {
        self::_update_item('last_update', $date, $this->id);
    }

    /**
     * update_last_add
     * updates the last_add of the catalog
     */
    public function update_last_add(): void
    {
        $date = time();
        self::_update_item('last_add', $date, $this->id);
    }

    /**
     * update_last_clean
     * This updates the last clean information
     */
    public function update_last_clean(): void
    {
        $date = time();
        self::_update_item('last_clean', $date, $this->id);
    }

    /**
     * update_settings
     * This function updates the basic setting of the catalog
     * @param array $data
     */
    public static function update_settings($data): void
    {
        $sql    = "UPDATE `catalog` SET `name` = ?, `rename_pattern` = ?, `sort_pattern` = ? WHERE `id` = ?";
        $params = array($data['name'], $data['rename_pattern'], $data['sort_pattern'], $data['catalog_id']);
        Dba::write($sql, $params);
    }

    /**
     * update_single_item
     * updates a single album,artist,song from the tag data and return the id. (if the artist/album changes it's updated)
     * this can be done by 75+
     * @param string $type
     * @param int $object_id
     * @param bool $api
     * @return array
     */
    public static function update_single_item($type, $object_id, $api = false): array
    {
        // Because single items are large numbers of things too
        set_time_limit(0);

        $return_id = $object_id;
        $songs     = array();
        $libitem   = 0;

        switch ($type) {
            case 'album':
                $libitem = new Album($object_id);
                $songs   = static::getSongRepository()->getByAlbum($object_id);
                break;
            case 'album_disk':
                $albumDisk = new AlbumDisk($object_id);
                $libitem   = new Album($albumDisk->album_id);
                $songs     = static::getSongRepository()->getByAlbumDisk($object_id);
                break;
            case 'artist':
                $libitem = new Artist($object_id);
                $songs   = static::getSongRepository()->getAllByArtist($object_id);
                break;
            case 'song':
                $songs[] = $object_id;
                break;
            case 'podcast_episode':
                $episode = new Podcast_Episode($object_id);
                self::update_media_from_tags($episode);

                return array(
                    'object_id' => $object_id,
                    'change' => true
                );
        } // end switch type

        if (!$api) {
            echo '<table class="tabledata striped-rows">' . "\n";
            echo '<thead><tr class="th-top">' . "\n";
            echo "<th>" . T_("Song") . "</th><th>" . T_("Status") . "</th>\n";
            echo "<tbody>\n";
        }
        $album  = false;
        $artist = false;
        $tags   = false;
        $maps   = false;
        foreach ($songs as $song_id) {
            $song   = new Song($song_id);
            $info   = self::update_media_from_tags($song);
            $file   = scrub_out($song->file);
            $diff   = array_key_exists('element', $info) && is_array($info['element']) && !empty($info['element']);
            $album  = ($album === true) || ($diff && array_key_exists('album', $info['element']));
            $artist = ($artist === true) || ($diff && array_key_exists('artist', $info['element']));
            $tags   = ($tags === true) || ($diff && array_key_exists('tags', $info['element']));
            $maps   = ($maps === true) || ($diff && array_key_exists('maps', $info));
            // don't echo useless info when using api
            if (array_key_exists('change', $info) && $info['change'] && (!$api)) {
                if ($diff && array_key_exists($type, $info['element'])) {
                    $element   = explode(' --> ', (string)$info['element'][$type]);
                    $return_id = (int)$element[1];
                }
                echo "<tr><td>" . $file . "</td><td>" . T_('Updated') . "</td></tr>\n";
            } elseif (array_key_exists('error', $info) && $info['error'] && (!$api)) {
                echo '<tr><td>' . $file . "</td><td>" . T_('Error') . "</td></tr>\n";
            } elseif (!$api) {
                echo '<tr><td>' . $file . "</td><td>" . T_('No Update Needed') . "</td></tr>\n";
            }
            flush();
        } // foreach songs
        if (!$api) {
            echo "</tbody></table>\n";
        }

        $albumRepository = self::getAlbumRepository();

        // Update the tags for parent items (Songs -> Albums -> Artist)
        if ($libitem instanceof Album) {
            $genres = self::getSongTags('album', $libitem->id);
            Tag::update_tag_list(implode(',', $genres), 'album', $libitem->id, true);
            if ($artist || $album || $tags || $maps) {
                $artists = array();
                // update the album artists
                foreach ($albumRepository->getArtistMap($libitem, 'album') as $albumArtist_id) {
                    $artists[] = $albumArtist_id;
                    $genres    = self::getSongTags('artist', $albumArtist_id);
                    Tag::update_tag_list(implode(',', $genres), 'artist', $albumArtist_id, true);
                }
                // update the song artists too
                foreach ($albumRepository->getArtistMap($libitem, 'song') as $songArtist_id) {
                    if (!in_array($songArtist_id, $artists)) {
                        $genres = self::getSongTags('artist', $songArtist_id);
                        Tag::update_tag_list(implode(',', $genres), 'artist', $songArtist_id, true);
                    }
                }
            }
        }
        // artist
        if ($libitem instanceof Artist) {
            // make sure albums are updated before the artist (include if you're just a song artist too)
            foreach (static::getAlbumRepository()->getAlbumByArtist($object_id) as $album_id) {
                $album_tags = self::getSongTags('album', $album_id);
                Tag::update_tag_list(implode(',', $album_tags), 'album', $album_id, true);
            }
            // refresh the artist tags after everything else
            $genres = self::getSongTags('artist', $libitem->id);
            Tag::update_tag_list(implode(',', $genres), 'artist', $libitem->id, true);
        }
        if ($type !== 'song') {
            // check counts
            if ($album || $maps) {
                Album::update_table_counts();
            }
            if ($artist || $maps) {
                Artist::update_table_counts();
            }
            // collect the garbage too
            if ($album || $artist || $maps) {
                self::getArtistRepository()->collectGarbage();
                self::getAlbumRepository()->collectGarbage();
            }
        }

        return array(
            'object_id' => $return_id,
            'change' => ($album || $artist || $maps || $tags)
        );
    }

    /**
     * update_media_from_tags
     * This is a 'wrapper' function calls the update function for the media
     * type in question
     * @param Song|Video|Podcast_Episode $media
     * @param array $gather_types
     * @param string $sort_pattern
     * @param string $rename_pattern
     * @return array
     */
    public static function update_media_from_tags(
        $media,
        $gather_types = array('music'),
        $sort_pattern = '',
        $rename_pattern = ''
    ): array {
        $array   = array();
        $catalog = self::create_from_id($media->catalog);
        if ($catalog === null) {
            debug_event(__CLASS__, 'update_media_from_tags: Error loading catalog ' . $media->catalog, 2);
            $array['error'] = true;

            return $array;
        }

        // retrieve the file if needed
        $streamConfiguration = $catalog->prepare_media($media);

        /** @var Song|Podcast_Episode|Video $media */
        if ($streamConfiguration === null) {
            $array['error'] = true;

            return $array;
        }
        /** @var Song|Podcast_Episode|Video $media */
        if (empty($streamConfiguration['file_path']) || Core::get_filesize(Core::conv_lc_file($streamConfiguration['file_path'])) == 0) {
            debug_event(__CLASS__, 'update_media_from_tags: Error loading file ' . $streamConfiguration['file_path'], 2);
            $array['error'] = true;

            return $array;
        }

        $type      = ObjectTypeToClassNameMapper::reverseMap(get_class($media));
        $functions = [
            'song' => static function ($results, $media) {
                return self::update_song_from_tags($results, $media);
            },
            'video' => static function ($results, $media) {
                return self::update_video_from_tags($results, $media);
            },
            'podcast_episode' => static function ($results, $media) {
                return self::update_podcast_episode_from_tags($results, $media);
            },
        ];

        $callable = $functions[$type];
        // try and get the tags from your file
        debug_event(__CLASS__, 'Reading tags from ' . $streamConfiguration['file_path'], 4);
        $extension = strtolower(pathinfo($streamConfiguration['file_path'], PATHINFO_EXTENSION));
        $results   = $catalog->get_media_tags($media, $gather_types, $sort_pattern, $rename_pattern);
        // for files without tags try to update from their file name instead
        if ($media->id && in_array($extension, array('wav', 'shn'))) {
            // match against your catalog 'Filename Pattern' and 'Folder Pattern'
            $patres  = VaInfo::parse_pattern($streamConfiguration['file_path'], $catalog->sort_pattern ?? '', $catalog->rename_pattern ?? '');
            $results = array_merge($results, $patres);
        }
        /** @var array $update */
        $update = $callable($results, $media);
        // remote catalogs should unlink the temp files if needed //TODO add other types of remote catalog
        if ($catalog instanceof Catalog_Seafile) {
            $catalog->clean_tmp_file($streamConfiguration['file_path']);
        }

        return $update;
    }

    /**
     * update_song_from_tags
     * Updates the song info based on tags; this is called from a bunch of
     * different places and passes in a full fledged song object, so it's a
     * static function.
     * FIXME: This is an ugly mess, this really needs to be consolidated and cleaned up.
     * @param array $results
     * @param Song $song
     * @return array
     * @throws ReflectionException
     */
    public static function update_song_from_tags($results, Song $song): array
    {
        //debug_event(__CLASS__, "update_song_from_tags results: " . print_r($results, true), 4);
        // info for the song table. This is all the primary file data that is song related
        $new_song       = new Song();
        $new_song->file = $results['file'];
        $new_song->year = (strlen((string)$results['year']) > 4)
            ? (int)substr($results['year'], -4, 4)
            : (int)($results['year']);
        $new_song->disk         = (Album::sanitize_disk($results['disk']) > 0) ? Album::sanitize_disk($results['disk']) : 1;
        $new_song->disksubtitle = $results['disksubtitle'];
        $new_song->title        = self::check_length(self::check_title($results['title'], $new_song->file));
        $new_song->bitrate      = $results['bitrate'];
        $new_song->rate         = $results['rate'];
        $new_song->mode         = (in_array($results['mode'], ['vbr', 'cbr', 'abr'])) ? $results['mode'] : 'vbr';
        $new_song->channels     = $results['channels'];
        $new_song->size         = $results['size'];
        $new_song->time         = (strlen((string)$results['time']) > 5)
            ? (int)substr($results['time'], -5, 5)
            : (int)($results['time']);
        if ($new_song->time < 0) {
            // fall back to last time if you fail to scan correctly
            $new_song->time = $song->time;
        }
        $new_song->track    = self::check_track((string)$results['track']);
        $new_song->mbid     = $results['mb_trackid'];
        $new_song->composer = self::check_length($results['composer']);
        $new_song->mime     = $results['mime'];

        // info for the song_data table. used in Song::update_song
        $new_song->comment = $results['comment'];
        $new_song->lyrics  = str_replace(
            ["\r\n", "\r", "\n"],
            '<br />',
            strip_tags($results['lyrics'])
        );
        if (isset($results['license'])) {
            $licenseRepository = static::getLicenseRepository();
            $licenseName       = (string) $results['license'];
            $licenseId         = $licenseRepository->find($licenseName);

            if ($licenseId === 0) {
                $license = $licenseRepository->prototype()
                    ->setName($licenseName);

                $license->save();

                $licenseId = $license->getId();
            }

            $new_song->license = $licenseId;
        } else {
            $new_song->license = null;
        }
        $new_song->label = isset($results['publisher']) ? self::check_length($results['publisher'], 128) : null;
        if (!empty($song->label) && AmpConfig::get('label')) {
            // create the label if missing
            foreach (array_map('trim', explode(';', $new_song->label)) as $label_name) {
                Label::helper($label_name);
            }
        }
        $new_song->language              = self::check_length($results['language'], 128);
        $new_song->replaygain_track_gain = (!is_null($results['replaygain_track_gain'])) ? (float) $results['replaygain_track_gain'] : null;
        $new_song->replaygain_track_peak = (!is_null($results['replaygain_track_peak'])) ? (float) $results['replaygain_track_peak'] : null;
        $new_song->replaygain_album_gain = (!is_null($results['replaygain_album_gain'])) ? (float) $results['replaygain_album_gain'] : null;
        $new_song->replaygain_album_peak = (!is_null($results['replaygain_album_peak'])) ? (float) $results['replaygain_album_peak'] : null;
        $new_song->r128_track_gain       = (!is_null($results['r128_track_gain'])) ? (int) $results['r128_track_gain'] : null;
        $new_song->r128_album_gain       = (!is_null($results['r128_album_gain'])) ? (int) $results['r128_album_gain'] : null;

        // genre is used in the tag and tag_map tables
        $tag_array = array();
        if (!empty($results['genre'])) {
            if (!is_array($results['genre'])) {
                $results['genre'] = array($results['genre']);
            }
            // check if this thing has been renamed into something else
            foreach ($results['genre'] as $tagName) {
                $merged = Tag::construct_from_name($tagName);
                if ($merged->isNew() === false && $merged->is_hidden) {
                    foreach ($merged->get_merged_tags() as $merged_tag) {
                        $tag_array[] = $merged_tag['name'];
                    }
                } else {
                    $tag_array[] = $tagName;
                }
            }
        }
        $new_song->tags = $tag_array;
        $tags           = Tag::get_object_tags('song', $song->id);
        if ($tags) {
            foreach ($tags as $tag) {
                $song->tags[] = $tag['name'];
            }
        }
        // info for the artist table.
        $artist           = self::check_length($results['artist']);
        $artist_mbid      = $results['mb_artistid'];
        $albumartist_mbid = $results['mb_albumartistid'];
        // info for the album table.
        $album      = self::check_length($results['album']);
        $album_mbid = $results['mb_albumid'];
        // year is also included in album
        $album_mbid_group = $results['mb_albumid_group'];
        $release_type     = self::check_length($results['release_type'], 32);
        $release_status   = $results['release_status'];
        $albumartist      = (!empty($results['albumartist']))
            ? self::check_length($results['albumartist'])
            : $song->get_album_artist_fullname();
        $albumartist      = $albumartist ?? null;
        $original_year    = $results['original_year'];
        $barcode          = self::check_length($results['barcode'], 64);
        $catalog_number   = self::check_length($results['catalog_number'], 64);
        $version          = self::check_length($results['version'], 64);

        // info for the artist_map table.
        $artists_array          = $results['artists'] ?? array();
        $artist_mbid_array      = $results['mb_artistid_array'] ?? array();
        $albumartist_mbid_array = $results['mb_albumartistid_array'] ?? array();
        // if you have an artist array this will be named better than what your tags will give you
        if (!empty($artists_array)) {
            if (!empty($artist) && !empty($albumartist) && $artist == $albumartist) {
                $albumartist = $artists_array[0];
            }
            $artist = $artists_array[0];
        }
        $is_upload_artist = false;
        if ($song->artist) {
            $is_upload_artist = Artist::is_upload($song->artist);
            if ($is_upload_artist) {
                debug_event(__CLASS__, "$song->artist : is an uploaded song artist", 4);
                $artist_mbid_array = array();
            }
        }
        $is_upload_albumartist = false;
        if ($song->album && $song->albumartist) {
            $is_upload_albumartist = Artist::is_upload($song->albumartist);
            if ($is_upload_albumartist) {
                debug_event(__CLASS__, "$song->albumartist : is an uploaded album artist", 4);
                $albumartist_mbid_array = array();
            }
        }
        // check whether this artist exists (and the album_artist)
        $new_song->artist = ($is_upload_artist)
            ? $song->artist
            : Artist::check($artist, $artist_mbid);
        if ($albumartist || !empty($song->albumartist)) {
            $new_song->albumartist = ($is_upload_albumartist || !$albumartist)
                ? $song->albumartist
                : Artist::check($albumartist, $albumartist_mbid);
            if (!$new_song->albumartist) {
                $new_song->albumartist = $song->albumartist;
            }
        }
        if (!$new_song->artist) {
            $new_song->artist = $song->artist;
        }

        // check whether this album exists
        $new_song->album = ($is_upload_albumartist)
            ? $song->album
            : Album::check($song->getCatalogId(), $album, $new_song->year, $album_mbid, $album_mbid_group, $new_song->albumartist, $release_type, $release_status, $original_year, $barcode, $catalog_number, $version);
        if (!$new_song->album) {
            $new_song->album = $song->album;
        }

        $albumRepository = self::getAlbumRepository();
        $new_song_album  = new Album($new_song->album);

        // get the artists / album_artists for this song
        $songArtist_array  = array($new_song->artist);
        $albumArtist_array = array($new_song->albumartist);
        // artist_map stores song and album against the artist_id
        $artist_map_song  = Artist::get_artist_map('song', $song->id);
        $artist_map_album = Artist::get_artist_map('album', $new_song->album);
        // album_map stores song_artist and album_artist against the album_id
        $album_map_songArtist  = $albumRepository->getArtistMap($new_song_album, 'song');
        $album_map_albumArtist = $albumRepository->getArtistMap($new_song_album, 'album');
        // don't update counts unless something changes
        $map_change = false;

        // add song artists with a valid mbid to the list
        if (!empty($artist_mbid_array)) {
            foreach ($artist_mbid_array as $song_artist_mbid) {
                $songArtist_id = Artist::check_mbid($song_artist_mbid);
                if ($songArtist_id > 0 && !in_array($songArtist_id, $songArtist_array)) {
                    $songArtist_array[] = $songArtist_id;
                }
            }
        }
        // add song artists found by name to the list (Ignore artist names when we have the same amount of MBID's)
        if (!empty($artists_array) && count($artists_array) > count($artist_mbid_array)) {
            foreach ($artists_array as $artist_name) {
                $songArtist_id = (int)Artist::check($artist_name);
                if ($songArtist_id > 0 && !in_array($songArtist_id, $songArtist_array)) {
                    $songArtist_array[] = $songArtist_id;
                }
            }
        }
        // map every song artist we've found
        foreach ($songArtist_array as $songArtist_id) {
            if ((int)$songArtist_id > 0 && !in_array($songArtist_id, $artist_map_song)) {
                $artist_map_song[] = (int)$songArtist_id;
                Artist::add_artist_map($songArtist_id, 'song', $song->id);
                if ($song->played) {
                    Stats::duplicate_map('song', $song->id, 'artist', (int)$songArtist_id);
                }
                $map_change = true;
            }
            if ((int)$songArtist_id > 0 && !in_array($songArtist_id, $album_map_songArtist)) {
                $album_map_songArtist[] = (int)$songArtist_id;
                Album::add_album_map($new_song->album, 'song', (int)$songArtist_id);
                if ($song->played) {
                    Stats::duplicate_map('song', $song->id, 'artist', (int)$songArtist_id);
                }
                $map_change = true;
            }
        }
        // add album artists to the list
        if (!empty($albumartist_mbid_array)) {
            foreach ($albumartist_mbid_array as $album_artist_mbid) {
                $albumArtist_id = Artist::check_mbid($album_artist_mbid);
                if ($albumArtist_id > 0 && !in_array($albumArtist_id, $albumArtist_array)) {
                    $albumArtist_array[] = $albumArtist_id;
                }
            }
        }
        // map every album artist we've found
        foreach ($albumArtist_array as $albumArtist_id) {
            if ((int)$albumArtist_id > 0 && !in_array($albumArtist_id, $artist_map_album)) {
                $artist_map_album[] = (int)$albumArtist_id;
                Artist::add_artist_map($albumArtist_id, 'album', $new_song->album);
                $map_change = true;
            }
            if ((int)$albumArtist_id > 0 && !in_array($albumArtist_id, $album_map_albumArtist)) {
                $album_map_albumArtist[] = (int)$albumArtist_id;
                Album::add_album_map($new_song->album, 'album', (int)$albumArtist_id);
                $map_change = true;
            }
        }
        // clean up the mapped things that are missing after the update
        foreach ($artist_map_song as $existing_map) {
            if (!in_array($existing_map, $songArtist_array)) {
                Artist::remove_artist_map($existing_map, 'song', $song->id);
                Album::check_album_map($song->album, 'song', $existing_map);
                if ($song->played) {
                    Stats::delete_map('song', $song->id, 'artist', $existing_map);
                }
                $map_change = true;
            }
        }
        foreach ($artist_map_song as $existing_map) {
            $not_found = !in_array($existing_map, $songArtist_array);
            // remove album song map if song artist is changed OR album changes
            if ($not_found || ($song->album != $new_song->album)) {
                Album::check_album_map($song->album, 'song', $existing_map);
                $map_change = true;
            }
            // only delete play count on song artist change
            if ($not_found && $song->played) {
                Stats::delete_map('song', $song->id, 'artist', $existing_map);
                $map_change = true;
            }
        }
        foreach ($artist_map_album as $existing_map) {
            if (!in_array($existing_map, $albumArtist_array)) {
                Artist::remove_artist_map($existing_map, 'album', $song->album);
                Album::check_album_map($song->album, 'album', $existing_map);
                $map_change = true;
            }
        }
        foreach ($album_map_songArtist as $existing_map) {
            // check song maps in the album_map table (because this is per song we need to check the whole album)
            if (Album::check_album_map($song->album, 'song', $existing_map)) {
                $map_change = true;
            }
        }
        foreach ($album_map_albumArtist as $existing_map) {
            if (!in_array($existing_map, $albumArtist_array)) {
                Album::remove_album_map($song->album, 'album', $existing_map);
                $map_change = true;
            }
        }

        if ($artist_mbid) {
            $new_song->artist_mbid = $artist_mbid;
        }
        if ($album_mbid) {
            $new_song->album_mbid = $album_mbid;
        }
        if ($albumartist_mbid) {
            $new_song->albumartist_mbid = $albumartist_mbid;
        }

        /* Since we're doing a full compare make sure we fill the extended information */
        $song->fill_ext_info();

        $metadataManager = self::getMetadataManager();

        if ($metadataManager->isCustomMetadataEnabled()) {
            $ctags = self::filterMetadata($song, $results);
            //debug_event(__CLASS__, "get_clean_metadata " . print_r($ctags, true), 4);
            foreach ($ctags as $tag => $value) {
                $metadataManager->updateOrAddMetadata($song, $tag, (string) $value);
            }

            /** @var Metadata $metadata */
            foreach ($metadataManager->getMetadata($song) as $metadata) {
                $field = $metadata->getField();

                if ($field === null) {
                    debug_event(__CLASS__, "delete metadata with unknown field ", 4);

                    $metadataManager->deleteMetadata($metadata);
                    continue;
                }

                $metaName = $field->getName();

                if (!array_key_exists($metaName, $ctags)) {
                    debug_event(__CLASS__, "delete metadata field " . $metaName, 4);
                    $metadataManager->deleteMetadata($metadata);
                }
            }
        }

        // Duplicate arts if required
        if (($song->artist > 0 && $new_song->artist) && $song->artist != $new_song->artist) {
            if (!Art::has_db($new_song->artist, 'artist')) {
                Art::duplicate('artist', $song->artist, $new_song->artist);
            }
        }
        if (($song->albumartist > 0 && $new_song->albumartist) && $song->albumartist != $new_song->albumartist) {
            if (!Art::has_db($new_song->albumartist, 'artist')) {
                Art::duplicate('artist', $song->albumartist, $new_song->albumartist);
            }
        }
        if (($song->album > 0 && $new_song->album) && $song->album != $new_song->album) {
            if (!Art::has_db($new_song->album, 'album')) {
                Art::duplicate('album', $song->album, $new_song->album);
            }
        }
        if ($song->label && AmpConfig::get('label')) {
            $labelRepository = static::getLabelRepository();

            foreach (array_map('trim', explode(';', $song->label)) as $label_name) {
                $label_id = Label::helper($label_name) ?? $labelRepository->lookup($label_name);
                if ((int)$label_id > 0) {
                    $label = $labelRepository->findById($label_id);
                    if ($label !== null) {
                        $artists = $label->get_artists();
                        if ($song->artist && !in_array($song->artist, $artists)) {
                            debug_event(__CLASS__, "$song->artist: adding association to $label->name", 4);
                            $labelRepository->addArtistAssoc($label->id, $song->artist, new DateTime());
                        }
                    }
                }
            }
        }

        $info = Song::compare_song_information($song, $new_song);
        if ($info['change']) {
            debug_event(__CLASS__, "$song->file : differences found, updating database", 4);

            // Update the song and song_data table
            Song::update_song($song->id, $new_song);

            // If you've migrated from an existing artist you need to migrate their data
            if (($song->artist > 0 && $new_song->artist) && $song->artist != $new_song->artist) {
                self::migrate('artist', $song->artist, $new_song->artist, $song->id);
            }
            // albums changes also require album_disk changes
            if (($song->album > 0 && $new_song->album) && self::migrate('album', $song->album, $new_song->album, $song->id)) {
                $sql = "UPDATE IGNORE `album_disk` SET `album_id` = ? WHERE `id` = ?";
                Dba::write($sql, array($new_song->album, $song->get_album_disk()));
            }

            // a change on any song will update for the entire disk
            if ($new_song->disksubtitle !== $song->disksubtitle) {
                $sql = "UPDATE `album_disk` SET `disksubtitle` = ? WHERE `id` = ?";
                Dba::write($sql, array($new_song->disksubtitle, $song->get_album_disk()));
            }

            if ($song->tags != $new_song->tags) {
                // we do still care if there are no tags on your object
                $tag_comma = (!empty($new_song->tags))
                    ? implode(',', $new_song->tags)
                    : '';
                Tag::update_tag_list($tag_comma, 'song', $song->id, true);
            }
            if ($song->license != $new_song->license) {
                Song::update_license($new_song->license, $song->id);
            }
        } else {
            // always update the time when you update
            Song::update_utime($song->id);
        }

        // If song rating tag exists and is well formed (array user=>rating), update it
        if ($song->id && is_array($results) && array_key_exists('rating', $results) && is_array($results['rating'])) {
            // For each user's ratings, call the function
            foreach ($results['rating'] as $user => $rating) {
                debug_event(__CLASS__, "Updating rating for Song " . $song->id . " to $rating for user $user", 5);
                $o_rating = new Rating($song->id, 'song');
                $o_rating->set_rating((int)$rating, $user);
            }
        }
        if ($map_change) {
            $info['change'] = true;
            $info['maps']   = true;
            self::updateArtistTags($song->id);
            self::updateAlbumArtistTags($song->album);
        }

        return $info;
    }

    /**
     * @param array $results
     * @param Video $video
     * @return array
     */
    public static function update_video_from_tags($results, Video $video): array
    {
        /* Setup the vars */
        $new_video                = new Video();
        $new_video->file          = $results['file'];
        $new_video->title         = $results['title'];
        $new_video->size          = $results['size'];
        $new_video->video_codec   = $results['video_codec'];
        $new_video->audio_codec   = $results['audio_codec'];
        $new_video->resolution_x  = $results['resolution_x'];
        $new_video->resolution_y  = $results['resolution_y'];
        $new_video->time          = $results['time'];
        $new_video->release_date  = $results['release_date'] ?? null;
        $new_video->bitrate       = $results['bitrate'];
        $new_video->mode          = $results['mode'];
        $new_video->channels      = $results['channels'];
        $new_video->display_x     = $results['display_x'];
        $new_video->display_y     = $results['display_y'];
        $new_video->frame_rate    = $results['frame_rate'];
        $new_video->video_bitrate = (int) self::check_int($results['video_bitrate'], 4294967294, 0);
        $tags                     = Tag::get_object_tags('video', $video->id);
        if ($tags) {
            foreach ($tags as $tag) {
                $video->tags[] = $tag['name'];
            }
        }
        $new_video->tags = $results['genre'];

        $info = Video::compare_video_information($video, $new_video);
        if ($info['change']) {
            debug_event(__CLASS__, $video->file . " : differences found, updating database", 5);

            Video::update_video($video->id, $new_video);

            if ($video->tags != $new_video->tags) {
                Tag::update_tag_list(implode(',', $new_video->tags), 'video', $video->id, true);
            }
            Video::update_video_counts($video->id);
        } else {
            // always update the time when you update
            Video::update_utime($video->id);
        }

        return $info;
    }

    /**
     * @param array $results
     * @param Podcast_Episode $podcast_episode
     * @return array
     */
    public static function update_podcast_episode_from_tags($results, Podcast_Episode $podcast_episode): array
    {
        $sql = "UPDATE `podcast_episode` SET `file` = ?, `size` = ?, `time` = ?, `bitrate` = ?, `rate` = ?, `mode` = ?, `channels` = ?, `state` = 'completed' WHERE `id` = ?";
        Dba::write($sql, array($podcast_episode->file, $results['size'], $results['time'], $results['bitrate'], $results['rate'], $results['mode'], $results['channels'], $podcast_episode->id));

        $podcast_episode->size     = $results['size'];
        $podcast_episode->time     = $results['time'];
        $podcast_episode->bitrate  = $results['bitrate'];
        $podcast_episode->rate     = $results['rate'];
        $podcast_episode->mode     = (in_array($results['mode'], ['vbr', 'cbr', 'abr'])) ? $results['mode'] : 'vbr';
        $podcast_episode->channels = $results['channels'];

        $array            = array();
        $array['change']  = true;
        $array['element'] = false;

        return $array;
    }

    /**
     * Get rid of all tags found in the libraryItem
     * @param array<string, scalar> $metadata
     * @return array<string, scalar>
     */
    private static function filterMetadata(MetadataEnabledInterface $libraryItem, array $metadata): array
    {
        $metadataManager = self::getMetadataManager();

        // these fields seem to be ignored but should be removed
        $databaseFields = [
            'artists' => null,
            'mb_albumartistid_array' => null,
            'mb_artistid_array' => null,
            'original_year' => null,
            'release_status' => null,
            'release_type' => null,
            'originalyear' => null,
            'dynamic range (r128)' => null,
            'volume level (r128)' => null,
            'volume level (replaygain)' => null,
            'peak level (r128)' => null,
            'peak level (sample)' => null
        ];

        // Drops ignored keys from the metadata
        $tags = array_diff_key(
            $metadata,
            get_object_vars($libraryItem),
            array_flip($libraryItem->getIgnoredMetadataKeys()),
            $databaseFields,
            array_flip($metadataManager->getDisabledMetadataFields())
        );

        // filters empty metadata values
        return array_filter($tags);
    }

    /**
     * update the artist or album counts on catalog changes
     */
    public static function update_counts(): void
    {
        $update_time = self::get_update_info('update_counts', -1);
        $now_time    = time();
        // give the server a 30 min break for this help with load
        if ($update_time !== 0 && $update_time > ($now_time - 1800)) {
            return;
        }
        self::set_update_info('update_counts', $now_time);
        debug_event(__CLASS__, 'update_counts after catalog changes', 5);
        // missing map tables are pretty important
        $sql = "INSERT IGNORE INTO `artist_map` (`artist_id`, `object_type`, `object_id`) SELECT DISTINCT `song`.`artist` AS `artist_id`, 'song', `song`.`id` FROM `song` WHERE `song`.`artist` > 0 AND `song`.`artist` IS NOT NULL UNION SELECT DISTINCT `album`.`album_artist` AS `artist_id`, 'album', `album`.`id` FROM `album` WHERE `album`.`album_artist` > 0 AND `album`.`album_artist` IS NOT NULL;";
        Dba::write($sql);
        $sql = "INSERT IGNORE INTO `album_map` (`album_id`, `object_type`, `object_id`) SELECT DISTINCT `artist_map`.`object_id` AS `album_id`, 'album' AS `object_type`, `artist_map`.`artist_id` AS `object_id` FROM `artist_map` WHERE `artist_map`.`object_type` = 'album' AND `artist_map`.`object_id` IS NOT NULL UNION SELECT DISTINCT `song`.`album` AS `album_id`, 'song' AS `object_type`, `song`.`artist` AS `object_id` FROM `song` WHERE `song`.`album` IS NOT NULL UNION SELECT DISTINCT `song`.`album` AS `album_id`, 'song' AS `object_type`, `artist_map`.`artist_id` AS `object_id` FROM `artist_map` LEFT JOIN `song` ON `artist_map`.`object_type` = 'song' AND `artist_map`.`object_id` = `song`.`id` WHERE `song`.`album` IS NOT NULL AND `artist_map`.`object_type` = 'song';";
        Dba::write($sql);
        $sql = "INSERT IGNORE INTO `album_disk` (`album_id`, `disk`, `catalog`) SELECT DISTINCT `song`.`album` AS `album_id`, `song`.`disk` AS `disk`, `song`.`catalog` AS `catalog` FROM `song`;";
        Dba::write($sql);
        // do the longer updates over a larger stretch of time
        if ($update_time !== 0 && $update_time < ($now_time - 86400)) {
            // delete old maps in album_map table
            $sql        = "SELECT `album_map`.`album_id`, `album_map`.`object_id`, `album_map`.`object_type` FROM (SELECT * FROM `album_map` WHERE `object_type` = 'song') AS `album_map` LEFT JOIN (SELECT DISTINCT `artist_id`, `album` FROM (SELECT `artist_id`, `object_id` AS `song_id` FROM `artist_map` WHERE `object_type` = 'song') AS `artist_songs`, `song` WHERE `song_id` = `id`) AS `artist_map` ON `album_map`.`object_id` = `artist_map`.`artist_id` AND `album_map`.`album_id` = `artist_map`.`album` WHERE `artist_map`.`album` IS NULL;";
            $db_results = Dba::read($sql);
            while ($row = Dba::fetch_assoc($db_results)) {
                $sql = "DELETE FROM `album_map` WHERE `album_id` = ? AND `object_id` = ? AND `object_type` = ?;";
                Dba::write($sql, array($row['album_id'], $row['object_id'], $row['object_type']));
            }
            // this isn't really needed often and is slow
            Dba::write("DELETE FROM `recommendation_item` WHERE `recommendation` NOT IN (SELECT `id` FROM `recommendation`);");
            // Fill in null Agents with a value
            $sql = "UPDATE `object_count` SET `agent` = 'Unknown' WHERE `agent` IS NULL;";
            Dba::write($sql);
            // object_count.album
            $sql = "UPDATE IGNORE `object_count`, (SELECT `song_count`.`date`, `song`.`id` AS `songid`, `song`.`album`, `album_count`.`object_id` AS `albumid`, `album_count`.`user`, `album_count`.`agent`, `album_count`.`count_type` FROM `song` LEFT JOIN `object_count` AS `song_count` ON `song_count`.`object_type` = 'song' AND `song_count`.`count_type` = 'stream' AND `song_count`.`object_id` = `song`.`id` LEFT JOIN `object_count` AS `album_count` ON `album_count`.`object_type` = 'album' AND `album_count`.`count_type` = 'stream' AND `album_count`.`date` = `song_count`.`date` WHERE `song_count`.`date` IS NOT NULL AND `song`.`album` != `album_count`.`object_id` AND `album_count`.`count_type` = 'stream') AS `album_check` SET `object_count`.`object_id` = `album_check`.`album` WHERE `object_count`.`object_id` != `album_check`.`album` AND `object_count`.`object_type` = 'album' AND `object_count`.`date` = `album_check`.`date` AND `object_count`.`user` = `album_check`.`user` AND `object_count`.`agent` = `album_check`.`agent` AND `object_count`.`count_type` = `album_check`.`count_type`;";
            Dba::write($sql);
            // object_count.artist
            $sql = "UPDATE IGNORE `object_count`, (SELECT `song_count`.`date`, MIN(`song`.`id`) AS `songid`, MIN(`song`.`artist`) AS `artist`, `artist_count`.`object_id` AS `artistid`, `artist_count`.`user`, `artist_count`.`agent`, `artist_count`.`count_type` FROM `song` LEFT JOIN `object_count` AS `song_count` ON `song_count`.`object_type` = 'song' AND `song_count`.`count_type` = 'stream' AND `song_count`.`object_id` = `song`.`id` LEFT JOIN `object_count` AS `artist_count` ON `artist_count`.`object_type` = 'artist' AND `artist_count`.`count_type` = 'stream' AND `artist_count`.`date` = `song_count`.`date` WHERE `song_count`.`date` IS NOT NULL AND `song`.`artist` != `artist_count`.`object_id` AND `artist_count`.`count_type` = 'stream' GROUP BY `artist_count`.`object_id`, `date`, `user`, `agent`, `count_type`) AS `artist_check` SET `object_count`.`object_id` = `artist_check`.`artist` WHERE `object_count`.`object_id` != `artist_check`.`artist` AND `object_count`.`object_type` = 'artist' AND `object_count`.`date` = `artist_check`.`date` AND `object_count`.`user` = `artist_check`.`user` AND `object_count`.`agent` = `artist_check`.`agent` AND `object_count`.`count_type` = `artist_check`.`count_type`;";
            Dba::write($sql);
        }
        // fix object_count table missing artist row
        debug_event(__CLASS__, 'update_counts object_count table missing artist row', 5);
        $sql = "INSERT IGNORE INTO `object_count` (`object_type`, `object_id`, `date`, `user`, `agent`, `geo_latitude`, `geo_longitude`, `geo_name`, `count_type`) SELECT 'artist', `artist_map`.`artist_id`, `object_count`.`date`, `object_count`.`user`, `object_count`.`agent`, `object_count`.`geo_latitude`, `object_count`.`geo_longitude`, `object_count`.`geo_name`, `object_count`.`count_type` FROM `object_count` LEFT JOIN `artist_map` on `object_count`.`object_type` = `artist_map`.`object_type` AND `object_count`.`object_id` = `artist_map`.`object_id` LEFT JOIN `object_count` AS `artist_check` ON `object_count`.`date` = `artist_check`.`date` AND `artist_check`.`object_type` = 'artist' AND `artist_check`.`object_id` = `artist_map`.`artist_id` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_id` IN (SELECT `id` FROM `song` WHERE `id` IN (SELECT `object_id` FROM `artist_map` WHERE `object_type` = 'song')) AND `artist_check`.`object_id` IS NULL UNION SELECT 'artist', `artist_map`.`artist_id`, `object_count`.`date`, `object_count`.`user`, `object_count`.`agent`, `object_count`.`geo_latitude`, `object_count`.`geo_longitude`, `object_count`.`geo_name`, `object_count`.`count_type` FROM `object_count` LEFT JOIN `artist_map` ON `object_count`.`object_type` = `artist_map`.`object_type` AND `object_count`.`object_id` = `artist_map`.`object_id` LEFT JOIN `object_count` AS `artist_check` ON `object_count`.`date` = `artist_check`.`date` AND `artist_check`.`object_type` = 'artist' AND `artist_check`.`object_id` = `artist_map`.`artist_id` WHERE `object_count`.`object_type` = 'album' AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_id` IN (SELECT `id` FROM `song` WHERE `id` IN (SELECT `object_id` FROM `artist_map` WHERE `object_type` = 'album')) AND `artist_check`.`object_id` IS NULL GROUP BY `artist_map`.`artist_id`, `object_count`.`object_type`, `object_count`.`object_id`, `object_count`.`date`, `object_count`.`user`, `object_count`.`agent`, `object_count`.`geo_latitude`, `object_count`.`geo_longitude`, `object_count`.`geo_name`, `object_count`.`count_type`;";
        Dba::write($sql);
        // fix object_count table missing album row
        debug_event(__CLASS__, 'update_counts object_count table missing album row', 5);
        $sql = "INSERT IGNORE INTO `object_count` (`object_type`, `object_id`, `date`, `user`, `agent`, `geo_latitude`, `geo_longitude`, `geo_name`, `count_type`) SELECT 'album', `song`.`album`, `object_count`.`date`, `object_count`.`user`, `object_count`.`agent`, `object_count`.`geo_latitude`, `object_count`.`geo_longitude`, `object_count`.`geo_name`, `object_count`.`count_type` FROM `object_count` LEFT JOIN `song` ON `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_id` = `song`.`id` LEFT JOIN `object_count` AS `album_count` ON `album_count`.`object_type` = 'album' AND `object_count`.`date` = `album_count`.`date` AND `object_count`.`user` = `album_count`.`user` AND `object_count`.`agent` = `album_count`.`agent` AND `object_count`.`count_type` = `album_count`.`count_type` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream' AND `album_count`.`id` IS NULL;";
        Dba::write($sql);
        // also clean up some bad data that might creep in
        Dba::write("UPDATE `artist` SET `prefix` = NULL WHERE `prefix` = '';");
        Dba::write("UPDATE `artist` SET `mbid` = NULL WHERE `mbid` = '';");
        Dba::write("UPDATE `artist` SET `summary` = NULL WHERE `summary` = '';");
        Dba::write("UPDATE `artist` SET `placeformed` = NULL WHERE `placeformed` = '';");
        Dba::write("UPDATE `artist` SET `yearformed` = NULL WHERE `yearformed` = 0;");
        Dba::write("UPDATE `album` SET `album_artist` = NULL WHERE `album_artist` = 0;");
        Dba::write("UPDATE `album` SET `prefix` = NULL WHERE `prefix` = '';");
        Dba::write("UPDATE `album` SET `mbid` = NULL WHERE `mbid` = '';");
        Dba::write("UPDATE `album` SET `mbid_group` = NULL WHERE `mbid_group` = '';");
        Dba::write("UPDATE `album` SET `release_type` = NULL WHERE `release_type` = '';");
        Dba::write("UPDATE `album` SET `original_year` = NULL WHERE `original_year` = 0;");
        Dba::write("UPDATE `album` SET `barcode` = NULL WHERE `barcode` = '';");
        Dba::write("UPDATE `album` SET `catalog_number` = NULL WHERE `catalog_number` = '';");
        Dba::write("UPDATE `album` SET `release_status` = NULL WHERE `release_status` = '';");
        // song.played might have had issues
        $sql = "UPDATE `song` SET `song`.`played` = 0 WHERE `song`.`played` = 1 AND `song`.`id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'song' AND `count_type` = 'stream');";
        Dba::write($sql);
        $sql = "UPDATE `song` SET `song`.`played` = 1 WHERE `song`.`played` = 0 AND `song`.`id` IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'song' AND `count_type` = 'stream');";
        Dba::write($sql);
        // fix up incorrect total_count values too
        $sql = "UPDATE `song` SET `total_count` = 0 WHERE `total_count` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream');";
        Dba::write($sql);
        $sql = "UPDATE `song` SET `total_skip` = 0 WHERE `total_skip` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream');";
        Dba::write($sql);
        if (AmpConfig::get('podcast')) {
            //debug_event(__CLASS__, 'update_counts podcast_episode table', 5);
            // fix object_count table missing podcast row
            $sql        = "SELECT `podcast_episode`.`podcast`, `object_count`.`date`, `object_count`.`user`, `object_count`.`agent`, `object_count`.`geo_latitude`, `object_count`.`geo_longitude`, `object_count`.`geo_name`, `object_count`.`count_type` FROM `object_count` LEFT JOIN `podcast_episode` ON `object_count`.`object_type` = 'podcast_episode' AND `object_count`.`count_type` = 'stream' AND `object_count`.`object_id` = `podcast_episode`.`id` LEFT JOIN `object_count` AS `podcast_count` ON `podcast_count`.`object_type` = 'podcast' AND `object_count`.`date` = `podcast_count`.`date` AND `object_count`.`user` = `podcast_count`.`user` AND `object_count`.`agent` = `podcast_count`.`agent` AND `object_count`.`count_type` = `podcast_count`.`count_type` WHERE `object_count`.`count_type` = 'stream' AND `object_count`.`object_type` = 'podcast_episode' AND `podcast_count`.`id` IS NULL LIMIT 100;";
            $db_results = Dba::read($sql);
            while ($row = Dba::fetch_assoc($db_results)) {
                $sql = "INSERT IGNORE INTO `object_count` (`object_type`, `object_id`, `count_type`, `date`, `user`, `agent`, `geo_latitude`, `geo_longitude`, `geo_name`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
                Dba::write($sql, array('podcast', $row['podcast'], $row['count_type'], $row['date'], $row['user'], $row['agent'], $row['geo_latitude'], $row['geo_longitude'], $row['geo_name']));
            }
            $sql = "UPDATE `podcast_episode` SET `total_count` = 0 WHERE `total_count` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'podcast_episode' AND `object_count`.`count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `podcast_episode` SET `total_skip` = 0 WHERE `total_skip` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'podcast_episode' AND `object_count`.`count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `podcast_episode` SET `podcast_episode`.`played` = 0 WHERE `podcast_episode`.`played` = 1 AND `podcast_episode`.`id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'podcast_episode' AND `count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `podcast_episode` SET `podcast_episode`.`played` = 1 WHERE `podcast_episode`.`played` = 0 AND `podcast_episode`.`id` IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'podcast_episode' AND `count_type` = 'stream');";
            Dba::write($sql);
            // podcast_episode.total_count
            $sql = "UPDATE `podcast_episode`, (SELECT COUNT(`object_count`.`object_id`) AS `total_count`, `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'podcast_episode' AND `object_count`.`count_type` = 'stream' GROUP BY `object_count`.`object_id`) AS `object_count` SET `podcast_episode`.`total_count` = `object_count`.`total_count` WHERE `podcast_episode`.`total_count` != `object_count`.`total_count` AND `podcast_episode`.`id` = `object_count`.`object_id`;";
            Dba::write($sql);
            // podcast_episode.played
            $sql = "UPDATE `podcast_episode` SET `played` = 0 WHERE `total_count` = 0 and `played` = 1;";
            Dba::write($sql);
            // podcast.total_count
            $sql = "UPDATE `podcast`, (SELECT SUM(`podcast_episode`.`total_count`) AS `total_count`, `podcast` FROM `podcast_episode` GROUP BY `podcast_episode`.`podcast`) AS `object_count` SET `podcast`.`total_count` = `object_count`.`total_count` WHERE `podcast`.`total_count` != `object_count`.`total_count` AND `podcast`.`id` = `object_count`.`podcast`;";
            Dba::write($sql);
            // podcast.total_skip
            $sql = "UPDATE `podcast`, (SELECT SUM(`podcast_episode`.`total_skip`) AS `total_skip`, `podcast` FROM `podcast_episode` GROUP BY `podcast_episode`.`podcast`) AS `object_count` SET `podcast`.`total_skip` = `object_count`.`total_skip` WHERE `podcast`.`total_skip` != `object_count`.`total_skip` AND `podcast`.`id` = `object_count`.`podcast`;";
            Dba::write($sql);
            // song.total_count
            $sql = "UPDATE `song`, (SELECT COUNT(`object_count`.`object_id`) AS `total_count`, `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'stream' GROUP BY `object_count`.`object_id`) AS `object_count` SET `song`.`total_count` = `object_count`.`total_count` WHERE `song`.`total_count` != `object_count`.`total_count` AND `song`.`id` = `object_count`.`object_id`;";
            Dba::write($sql);
            // song.total_skip
            $sql = "UPDATE `song`, (SELECT COUNT(`object_count`.`object_id`) AS `total_skip`, `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'song' AND `object_count`.`count_type` = 'skip' GROUP BY `object_count`.`object_id`) AS `object_count` SET `song`.`total_skip` = `object_count`.`total_skip` WHERE `song`.`total_skip` != `object_count`.`total_skip` AND `song`.`id` = `object_count`.`object_id`;";
            Dba::write($sql);
            // song.played
            $sql = "UPDATE `song` SET `played` = 0 WHERE `total_count` = 0 and `played` = 1;";
            Dba::write($sql);
        }
        if (AmpConfig::get('allow_video')) {
            //debug_event(__CLASS__, 'update_counts video table', 5);
            $sql = "UPDATE `video` SET `total_count` = 0 WHERE `total_count` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'video' AND `object_count`.`count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `video` SET `total_skip` = 0 WHERE `total_skip` > 0 AND `id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'video' AND `object_count`.`count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `video` SET `video`.`played` = 0 WHERE `video`.`played` = 1 AND `video`.`id` NOT IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'video' AND `count_type` = 'stream');";
            Dba::write($sql);
            $sql = "UPDATE `video` SET `video`.`played` = 1 WHERE `video`.`played` = 0 AND `video`.`id` IN (SELECT `object_id` FROM `object_count` WHERE `object_type` = 'video' AND `count_type` = 'stream');";
            Dba::write($sql);
            // video.total_count
            $sql = "UPDATE `video`, (SELECT COUNT(`object_count`.`object_id`) AS `total_count`, `object_id` FROM `object_count` WHERE `object_count`.`object_type` = 'video' AND `object_count`.`count_type` = 'stream' GROUP BY `object_count`.`object_id`) AS `object_count` SET `video`.`total_count` = `object_count`.`total_count` WHERE `video`.`total_count` != `object_count`.`total_count` AND `video`.`id` = `object_count`.`object_id`;";
            Dba::write($sql);
            // video.played
            $sql = "UPDATE `video` SET `played` = 0 WHERE `total_count` = 0 and `played` = 1;";
            Dba::write($sql);
        }
        Artist::update_table_counts();
        Album::update_table_counts();

        // update server total counts
        debug_event(__CLASS__, 'update_counts server total counts', 5);
        $catalog_disable = AmpConfig::get('catalog_disable');
        // 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) {
            $enabled_sql = ($catalog_disable) ? " WHERE `$table`.`enabled` = '1'" : '';
            $sql         = "SELECT COUNT(`id`), IFNULL(SUM(`time`), 0), IFNULL(SUM(`size`)/1024/1024, 0) FROM `$table`" . $enabled_sql;
            $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 += $row[2] ?? 0;
            self::set_update_info($table, (int)($row[0] ?? 0));
        }
        self::set_update_info('items', $items);
        self::set_update_info('time', $time);
        self::set_update_info('size', $size);

        $song_tables = array('artist', 'album');
        foreach ($song_tables as $table) {
            $sql        = "SELECT COUNT(DISTINCT(`$table`)) FROM `song`";
            $db_results = Dba::read($sql);
            $row        = Dba::fetch_row($db_results);
            self::set_update_info($table, (int)($row[0] ?? 0));
        }
        // 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';";
        $db_results = Dba::read($sql);
        $row        = Dba::fetch_row($db_results);
        self::set_update_info('album_disk', (int)($row[0] ?? 0));

        $list_tables = array('search', 'playlist', 'live_stream', 'podcast', 'user', 'catalog', 'label', 'tag', 'share', 'license');
        foreach ($list_tables as $table) {
            $sql        = "SELECT COUNT(`id`) FROM `$table`";
            $db_results = Dba::read($sql);
            $row        = Dba::fetch_row($db_results);
            self::set_update_info($table, (int)($row[0] ?? 0));
        }
        debug_event(__CLASS__, 'update_counts User::update_counts()', 5);
        // user accounts may have different items to return based on catalog_filter so lets set those too
        User::update_counts();
        debug_event(__CLASS__, 'update_counts completed', 5);
    }

    /**
     * @param array<string, scalar> $metadata
     */
    public function addMetadata(MetadataEnabledInterface $libraryItem, array $metadata): void
    {
        $metadataManager = self::getMetadataManager();

        $tags = self::filterMetadata($libraryItem, $metadata);

        foreach ($tags as $tag => $value) {
            $metadataManager->addMetadata($libraryItem, $tag, (string) $value);
        }
    }

    /**
     * @param array<string, scalar> $tags
     */
    protected function updateMetadata(MetadataEnabledInterface $item, array $tags): void
    {
        $metadataManager = self::getMetadataManager();

        $tags = self::filterMetadata($item, $tags);

        foreach ($tags as $tag => $value) {
            $metadataManager->updateOrAddMetadata($item, $tag, (string) $value);
        }
    }

    /**
     * get_media_tags
     * @param Song|Video|Podcast_Episode $media
     * @param array $gather_types
     * @param string $sort_pattern
     * @param string $rename_pattern
     * @return array
     */
    public function get_media_tags($media, $gather_types, $sort_pattern, $rename_pattern): array
    {
        // Check for patterns
        if (!$sort_pattern || !$rename_pattern) {
            $sort_pattern   = $this->sort_pattern;
            $rename_pattern = $this->rename_pattern;
        }
        if ($media->file === null) {
            return array();
        }

        $vainfo = self::getUtilityFactory()->createVaInfo(
            $media->file,
            $gather_types,
            '',
            '',
            (string) $sort_pattern,
            (string) $rename_pattern
        );
        try {
            $vainfo->gather_tags();
        } catch (Exception $error) {
            debug_event(__CLASS__, 'Error ' . $error->getMessage(), 1);

            return array();
        }

        $key = VaInfo::get_tag_type($vainfo->tags);

        return VaInfo::clean_tag_info($vainfo->tags, $key, $media->file);
    }

    /**
     * get_gather_types
     * @param string $media_type
     * @return array
     */
    public function get_gather_types($media_type = ''): array
    {
        $catalog_media_type = $this->gather_types;
        if (empty($catalog_media_type)) {
            $catalog_media_type = "music";
        }
        $types = explode(',', $catalog_media_type);

        if ($media_type == "video") {
            $types = array_diff($types, array('music'));
        }

        if ($media_type == "music") {
            $types = array_diff($types, array('personal_video', 'movie', 'tvshow', 'clip'));
        }

        return $types;
    }

    /**
     * get_table_from_type
     * @param null|string $gather_type
     */
    public static function get_table_from_type($gather_type): string
    {
        switch ($gather_type) {
            case 'clip':
            case 'tvshow':
            case 'movie':
            case 'personal_video':
                $table = 'video';
                break;
            case 'podcast':
                $table = 'podcast_episode';
                break;
            case 'music':
            default:
                $table = 'song';
                break;
        }

        return $table;
    }

    /**
     * clean_empty_albums
     */
    public static function clean_empty_albums(): void
    {
        $sql        = "SELECT `id`, `album_artist` FROM `album` WHERE NOT EXISTS (SELECT `id` FROM `song` WHERE `song`.`album` = `album`.`id`);";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            $sql = "DELETE FROM `album` WHERE `id` = ?";
            Dba::write($sql, array($row['id']));
        }
        // these files have missing albums so you can't verify them without updating from tags first
        $sql        = "SELECT `id` FROM `song` WHERE `album` in (SELECT `album_id` FROM `album_map` WHERE `album_id` NOT IN (SELECT `id` from `album`));";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            self::update_single_item('song', $row['id'], true);
        }
    }

    /**
     * clean_duplicate_artists
     *
     * Artists that have the same mbid shouldn't be duplicated but can be created and updated based on names
     */
    public static function clean_duplicate_artists(): void
    {
        debug_event(__CLASS__, "Clean Artists with duplicate mbid's", 5);
        $sql        = "SELECT `mbid`, min(`id`) AS `minid`, max(`id`) AS `maxid` FROM `artist` WHERE `mbid` IS NOT NULL GROUP BY `mbid` HAVING count(`mbid`) >1;";
        $db_results = Dba::read($sql);
        while ($row = Dba::fetch_assoc($db_results)) {
            debug_event(__CLASS__, "clean_duplicate_artists " . $row['maxid'] . "=>" . $row['minid'], 5);
            // migrate linked tables first
            //Stats::migrate('artist', $row['maxid'], $row['minid']);
            Useractivity::migrate('artist', $row['maxid'], $row['minid']);
            Recommendation::migrate('artist', $row['maxid']);
            self::getShareRepository()->migrate('artist', (int) $row['maxid'], (int) $row['minid']);
            self::getShoutRepository()->migrate('artist', (int) $row['maxid'], (int) $row['minid']);
            Tag::migrate('artist', $row['maxid'], $row['minid']);
            Userflag::migrate('artist', $row['maxid'], $row['minid']);
            Label::migrate('artist', $row['maxid'], $row['minid']);
            Rating::migrate('artist', $row['maxid'], $row['minid']);
            self::getWantedRepository()->migrateArtist($row['maxid'], $row['minid']);
            Clip::migrate('artist', $row['maxid'], $row['minid']);
            self::migrate_map('artist', $row['maxid'], $row['minid']);

            // replace all songs and albums with the original artist
            Artist::migrate($row['maxid'], $row['minid']);
        }
        // remove the duplicates after moving everything
        self::getArtistRepository()->collectGarbage();
        self::getAlbumRepository()->collectGarbage();
    }

    /**
     * clean_catalog
     *
     * Cleans the catalog of files that no longer exist.
     */
    public function clean_catalog(): int
    {
        // We don't want to run out of time
        set_time_limit(0);

        debug_event(__CLASS__, 'Starting clean on ' . $this->name, 5);

        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            require Ui::find_template('show_clean_catalog.inc.php');
            ob_flush();
            flush();
        }

        $dead_total = $this->clean_catalog_proc();
        if ($dead_total > 0) {
            self::clean_empty_albums();
            self::clean_duplicate_artists();
        }

        debug_event(__CLASS__, 'clean finished, ' . $dead_total . ' removed from ' . $this->name, 4);

        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            Ui::show_box_top();
        }
        Ui::update_text(
            T_("Catalog Cleaned"),
            sprintf(nT_("%d file removed.", "%d files removed.", $dead_total), $dead_total)
        );
        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            Ui::show_box_bottom();
        }

        $this->update_last_clean();

        return $dead_total;
    }

    /**
     * verify_catalog
     * This function verify the catalog
     */
    public function verify_catalog(): bool
    {
        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            require Ui::find_template('show_verify_catalog.inc.php');
            ob_flush();
            flush();
        }

        $verified = $this->verify_catalog_proc();

        debug_event(__CLASS__, 'verify finished, ' . $verified . ' updated', 4);

        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            Ui::show_box_top();
        }
        Ui::update_text(
            T_("Catalog Verified"),
            sprintf(nT_('%d file updated.', '%d files updated.', $verified), $verified)
        );
        if (!defined('SSE_OUTPUT') && !defined('CLI')) {
            Ui::show_box_bottom();
        }

        return true;
    }

    /**
     * trim_prefix
     * Splits the prefix from the string
     * @param string $string
     * @param string $pattern
     * @return array
     */
    public static function trim_prefix($string, $pattern = null): array
    {
        $prefix_pattern = $pattern ?? '/^(' . implode('\\s|', explode('|', AmpConfig::get('catalog_prefix_pattern', 'The|An|A|Die|Das|Ein|Eine|Les|Le|La'))) . '\\s)(.*)/i';
        if (preg_match($prefix_pattern, $string, $matches)) {
            $string = trim((string)$matches[2]);
            $prefix = trim((string)$matches[1]);
        } else {
            $prefix = null;
        }

        return array(
            'string' => $string,
            'prefix' => $prefix
        );
    }

    /**
     * @param int|string|null $year
     */
    public static function normalize_year($year): int
    {
        if (empty($year)) {
            return 0;
        }

        $year = (int)($year);
        if ($year < 0 || $year > 9999) {
            return 0;
        }

        return $year;
    }

    /**
     * trim_slashed_list
     * Split items by configurable delimiter
     * Return first item as string = default
     * Return all items as array if doTrim = false passed as optional parameter
     * @param string|null $string
     * @param bool $doTrim
     * @return string|array
     */
    public static function trim_slashed_list($string, $doTrim = true)
    {
        $delimiters = static::getConfigContainer()->get(ConfigurationKeyEnum::ADDITIONAL_DELIMITERS);
        $pattern    = '~[\s]?(' . $delimiters . ')[\s]?~';
        $items      = preg_split($pattern, (string)$string);
        if (!$items) {
            return (string)$string;
        }
        $items = array_map('trim', $items);

        if (isset($items[0]) && $doTrim) {
            return $items[0];
        }

        return $items;
    }

    /**
     * trim_featuring
     * Splits artists featuring from the string
     * @param string $string
     * @return array
     */
    public static function trim_featuring($string): array
    {
        $items = preg_split("/ feat\. /i", $string);
        if (!$items) {
            return array($string);
        }

        return array_map('trim', $items);
    }

    /**
     * check_title
     * this checks to make sure something is
     * set on the title, if it isn't it looks at the
     * filename and tries to set the title based on that
     * @param string $title
     * @param string $file
     */
    public static function check_title($title, $file = ''): string
    {
        if (strlen(trim((string)$title)) < 1) {
            $title = Dba::escape($file) ?? '';
        }

        return $title;
    }

    /**
     * check_length
     * Check to make sure the string fits into the database
     * max_length is the maximum number of characters that the (varchar) column can hold
     * @param string $string
     * @param int $max_length
     */
    public static function check_length($string, $max_length = 255): string
    {
        $string = (string)$string;
        if (false !== $encoding = mb_detect_encoding($string, null, true)) {
            $string = trim(mb_substr($string, 0, $max_length, $encoding));
        } else {
            $string = trim(substr($string, 0, $max_length));
        }

        return $string;
    }

    /**
     * check_track
     * Check to make sure the track number fits into the database: max 32767, min -32767
     *
     * @param string $track
     */
    public static function check_track($track): int
    {
        $retval = ((int)$track > 32767 || (int)$track < -32767) ? (int)substr($track, -4, 4) : (int)$track;
        if ((int)$track !== $retval) {
            debug_event(__CLASS__, "check_track: '{" . $track . "}' out of range. Changed into '{" . $retval . "}'", 4);
        }

        return $retval;
    }

    /**
     * check_int
     * Check to make sure a number fits into the database
     *
     * @param int $my_int
     * @param int $max
     * @param int $min
     */
    public static function check_int($my_int, $max, $min): int
    {
        if ($my_int > $max) {
            return $max;
        }
        if ($my_int < $min) {
            return $min;
        }

        return $my_int;
    }

    /**
     * get_unique_string
     * Check to make sure the string doesn't have duplicate strings ({)e.g. "Enough Records; Enough Records")
     *
     * @param string $str_array
     */
    public static function get_unique_string($str_array): string
    {
        $array = array_unique(array_map('trim', explode(';', $str_array)));

        return implode($array);
    }

    /**
     * delete
     * Deletes the catalog and everything associated with it
     * @param int $catalog_id
     */
    public static function delete($catalog_id): bool
    {
        $params  = array($catalog_id);
        $catalog = self::create_from_id($catalog_id);
        if (!$catalog) {
            return false;
        }

        // Large catalog deletion can take time
        set_time_limit(0);

        $sql        = "DELETE FROM `song` WHERE `catalog` = ?";
        $db_results = Dba::write($sql, $params);
        if (!$db_results) {
            return false;
        }
        self::clean_empty_albums();

        $sql        = "DELETE FROM `video` WHERE `catalog` = ?";
        $db_results = Dba::write($sql, $params);
        if (!$db_results) {
            return false;
        }

        $sql        = "DELETE FROM `podcast` WHERE `catalog` = ?";
        $db_results = Dba::write($sql, $params);
        if (!$db_results) {
            return false;
        }

        $sql        = "DELETE FROM `live_stream` WHERE `catalog` = ?";
        $db_results = Dba::write($sql, $params);
        if (!$db_results) {
            return false;
        }

        $sql        = 'DELETE FROM `catalog_' . $catalog->get_type() . '` WHERE `catalog_id` = ?';
        $db_results = Dba::write($sql, $params);
        if (!$db_results) {
            return false;
        }

        // Next Remove the Catalog Entry it's self
        $sql = "DELETE FROM `catalog` WHERE `id` = ?";
        Dba::write($sql, $params);

        // run garbage collection
        static::getCatalogGarbageCollector()->collect();

        return true;
    }

    /**
     * Update the catalog mapping for various types
     * @param string $table
     */
    public static function update_mapping($table): void
    {
        // fill the data
        debug_event(__CLASS__, 'Update mapping for table: ' . $table, 5);
        if ($table == 'artist') {
            // insert catalog_map artists
            $sql = "INSERT IGNORE INTO `catalog_map` (`catalog_id`, `object_type`, `object_id`) SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` LEFT JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` LEFT JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'song_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` LEFT JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'album_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` LEFT JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL GROUP BY `catalog`, `artist_map`.`object_type`, `artist_map`.`artist_id`;";
            Dba::write($sql);
        } elseif ($table == 'playlist') {
            $sql = "INSERT IGNORE INTO `catalog_map` (`catalog_id`, `object_type`, `object_id`) SELECT `song`.`catalog`, 'playlist', `playlist`.`id` FROM `playlist` LEFT JOIN `playlist_data` ON `playlist`.`id`=`playlist_data`.`playlist` LEFT JOIN `song` ON `song`.`id` = `playlist_data`.`object_id` AND `playlist_data`.`object_type` = 'song' GROUP BY `song`.`catalog`, 'playlist', `playlist`.`id`;";
            Dba::write($sql);
        } else {
            // 'album', 'album_disk', 'song', 'video', 'podcast', 'podcast_episode', 'live_stream'
            $sql = "INSERT IGNORE INTO `catalog_map` (`catalog_id`, `object_type`, `object_id`) SELECT `$table`.`catalog`, '$table', `$table`.`id` FROM `$table` GROUP BY `$table`.`catalog`, '$table', `$table`.`id`;";
            Dba::write($sql);
        }
    }

    /**
     * Update the catalog_map table depending on table type
     * @param null|string $media_type
     */
    public static function update_catalog_map($media_type): void
    {
        if ($media_type == 'music') {
            self::update_mapping('artist');
            self::update_mapping('album');
            self::update_mapping('album_disk');
        } elseif ($media_type == 'podcast') {
            self::update_mapping('podcast');
            self::update_mapping('podcast_episode');
        } elseif (in_array($media_type, array('clip', 'tvshow', 'movie', 'personal_video'))) {
            self::update_mapping('video');
        }
    }

    /**
     * Update the catalog mapping for various types
     */
    public static function garbage_collect_mapping(): void
    {
        // delete non-existent maps
        $tables = ['song', 'album', 'video', 'podcast', 'podcast_episode', 'live_stream'];
        foreach ($tables as $type) {
            $sql = "DELETE FROM `catalog_map` USING `catalog_map` LEFT JOIN (SELECT DISTINCT `$type`.`catalog` AS `catalog_id`, `$type`.`id` AS `object_id` FROM `$type`) AS `valid_maps` ON `valid_maps`.`catalog_id` = `catalog_map`.`catalog_id` AND `valid_maps`.`object_id` = `catalog_map`.`object_id` WHERE `catalog_map`.`object_type` = '$type' AND `valid_maps`.`object_id` IS NULL;";
            Dba::write($sql);
        }
        // delete catalog_map artists
        $sql = "DELETE FROM `catalog_map` USING `catalog_map` LEFT JOIN (SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` INNER JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` INNER JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'song_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` INNER JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'album_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` INNER JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL GROUP BY `album`.`catalog`, `artist_map`.`object_type`, `artist_map`.`artist_id`) AS `valid_maps` ON `valid_maps`.`catalog_id` = `catalog_map`.`catalog_id` AND `valid_maps`.`object_id` = `catalog_map`.`object_id` AND `valid_maps`.`map_type` = `catalog_map`.`object_type` WHERE `catalog_map`.`object_type` IN ('artist', 'song_artist', 'album_artist') AND `valid_maps`.`object_id` IS NULL;";
        Dba::write($sql);
        // empty catalogs
        $sql = "DELETE FROM `catalog_map` WHERE `catalog_id` = 0";
        Dba::write($sql);
    }

    /**
     * Delete catalog filters that might have gone missing
     */
    public static function garbage_collect_filters(): void
    {
        Dba::write("DELETE FROM `catalog_filter_group_map` WHERE `group_id` NOT IN (SELECT `id` FROM `catalog_filter_group`);");
        Dba::write("DELETE FROM `catalog_filter_group_map` WHERE `catalog_id` NOT IN (SELECT `id` FROM `catalog`);");
        Dba::write("UPDATE `user` SET `catalog_filter_group` = 0 WHERE `catalog_filter_group` NOT IN (SELECT `id` FROM `catalog_filter_group`);");
        Dba::write("UPDATE IGNORE `catalog_filter_group` SET `id` = 0 WHERE `name` = 'DEFAULT' AND `id` > 0;");
    }

    /**
     * Update the catalog map for a single item
     */
    public static function update_map($catalog, $object_type, $object_id): void
    {
        debug_event(__CLASS__, "update_map $object_type: {{$object_id}}", 5);
        if ($object_type == 'artist') {
            // insert catalog_map artists
            $sql = "INSERT IGNORE INTO `catalog_map` (`catalog_id`, `object_type`, `object_id`) SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` LEFT JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` LEFT JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `song`.`catalog` AS `catalog_id`, 'song_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `song` LEFT JOIN `artist_map` ON `song`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'song' WHERE `artist_map`.`object_type` IS NOT NULL UNION SELECT DISTINCT `album`.`catalog` AS `catalog_id`, 'album_artist' AS `map_type`, `artist_map`.`artist_id` AS `object_id` FROM `album` LEFT JOIN `artist_map` ON `album`.`id` = `artist_map`.`object_id` AND `artist_map`.`object_type` = 'album' WHERE `artist_map`.`object_type` IS NOT NULL GROUP BY `catalog`, `artist_map`.`object_type`, `artist_map`.`artist_id`;";
            Dba::write($sql, array($object_id, $object_id, $object_id, $object_id));
        } elseif ($catalog > 0) {
            $sql = "INSERT IGNORE INTO `catalog_map` (`catalog_id`, `object_type`, `object_id`) VALUES (?, ?, ?);";
            Dba::write($sql, array($catalog, $object_type, $object_id));
        }
    }

    /**
     * Migrate an object associated catalog to a new object
     * @param string $object_type
     * @param int $old_object_id
     * @param int $new_object_id
     * @return PDOStatement|bool
     */
    public static function migrate_map($object_type, $old_object_id, $new_object_id)
    {
        $sql    = "UPDATE IGNORE `catalog_map` SET `object_id` = ? WHERE `object_type` = ? AND `object_id` = ?";
        $params = array($new_object_id, $object_type, $old_object_id);

        return Dba::write($sql, $params);
    }

    /**
     * Updates artist tags from given song id
     * @param int $song_id
     */
    protected static function updateArtistTags(int $song_id): void
    {
        foreach (Song::get_parent_array($song_id) as $artist_id) {
            $tags = self::getSongTags('artist', $artist_id);
            Tag::update_tag_list(implode(',', $tags), 'artist', $artist_id, true);
        }
    }

    /**
     * Updates artist tags from given song id
     * @param int $album_id
     */
    protected static function updateAlbumArtistTags(int $album_id): void
    {
        foreach (Song::get_parent_array($album_id, 'album') as $artist_id) {
            $tags = self::getSongTags('artist', $artist_id);
            Tag::update_tag_list(implode(',', $tags), 'artist', $artist_id, true);
        }
    }

    /**
     * Get all tags from all Songs from [type] (artist, album, ...)
     * @param string $type
     * @param int $object_id
     * @return array
     */
    protected static function getSongTags($type, $object_id): array
    {
        $tags = array();
        $sql  = ($type == 'artist')
            ? "SELECT `tag`.`name` FROM `tag` JOIN `tag_map` ON `tag`.`id` = `tag_map`.`tag_id` JOIN `song` ON `tag_map`.`object_id` = `song`.`id` WHERE `song`.`id` IN (SELECT `object_id` FROM `artist_map` WHERE `artist_id` = ? AND `object_type` = 'song') AND `tag_map`.`object_type` = 'song' GROUP BY `tag`.`id`, `tag`.`name`;"
            : "SELECT `tag`.`name` FROM `tag` JOIN `tag_map` ON `tag`.`id` = `tag_map`.`tag_id` JOIN `song` ON `tag_map`.`object_id` = `song`.`id` WHERE `song`.`$type` = ? AND `tag_map`.`object_type` = 'song' GROUP BY `tag`.`id`, `tag`.`name`;";
        $db_results = Dba::read($sql, array($object_id));
        while ($row = Dba::fetch_assoc($db_results)) {
            $tags[] = $row['name'];
        }

        return $tags;
    }

    /**
     * @param Album|AlbumDisk|Artist|Song|Video|Podcast_Episode|TvShow|TVShow_Episode|Label|TVShow_Season $libitem
     * @param int|null $user_id
     */
    public static function can_remove($libitem, $user_id = 0): bool
    {
        if (!$user_id) {
            $user    = Core::get_global('user');
            $user_id = $user->id ?? false;
        }

        if (!$user_id) {
            return false;
        }

        if (!AmpConfig::get('delete_from_disk')) {
            return false;
        }

        return (
            Access::check('interface', 75) ||
            ($libitem->get_user_owner() == $user_id && AmpConfig::get('upload_allow_remove'))
        );
    }

    /**
     * Return full path of the cached music file.
     * @param int $object_id
     * @param int $catalog_id
     * @param string $path
     * @param string $target
     */
    public static function get_cache_path($object_id, $catalog_id, $path = '', $target = ''): ?string
    {
        // need a destination and target filetype
        if (!is_dir($path) || empty($target)) {
            return null;
        }
        // make a folder per catalog
        if (!is_dir(rtrim(trim($path), '/') . '/' . $catalog_id)) {
            mkdir(rtrim(trim($path), '/') . '/' . $catalog_id, 0775, true);
        }
        // Create subdirectory based on the 2 last digit of the SongID. We prevent having thousands of file in one directory.
        $path .= '/' . $catalog_id . '/' . substr((string)$object_id, -1, 1) . '/' . substr((string)$object_id, -2, 1) . '/';
        if (!file_exists($path)) {
            mkdir($path, 0755, true);
        }

        return rtrim(trim($path), '/') . '/' . $object_id . '.' . $target;
    }

    /**
     * process_action
     * @param string $action
     * @param array|null $catalogs
     * @param array $options
     * @noinspection PhpMissingBreakStatementInspection
     */
    public static function process_action($action, $catalogs, $options = null): void
    {
        if (empty($options)) {
            $options = array(
                'gather_art' => false,
                'parse_playlist' => false
            );
        }
        // make sure parse_playlist is set
        if ($action == 'import_to_catalog') {
            $options['parse_playlist'] = true;
        }
        $catalog = null;

        switch ($action) {
            case 'add_to_all_catalogs':
                $catalogs = self::get_catalogs();
                // Intentional break fall-through
            case 'add_to_catalog':
            case 'import_to_catalog':
                $catalog_media_types = array();
                if ($catalogs) {
                    foreach ($catalogs as $catalog_id) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null) {
                            if ($catalog->add_to_catalog($options)) {
                                $catalog_media_types[] = $catalog->gather_types;
                            }
                        }
                    }

                    if (!defined('SSE_OUTPUT') && !defined('CLI')) {
                        echo AmpError::display('catalog_add');
                    }
                    foreach ($catalog_media_types as $catalog_media_type) {
                        if ($catalog_media_type == 'music') {
                            self::clean_empty_albums();
                            Album::update_album_artist();
                        }
                        self::update_catalog_map($catalog_media_type);
                    }
                }
                if (in_array('music', $catalog_media_types)) {
                    Artist::update_table_counts();
                    Album::update_table_counts();
                }
                break;
            case 'update_all_catalogs':
                $catalogs = self::get_catalogs();
                // Intentional break fall-through
            case 'update_catalog':
                if ($catalogs) {
                    foreach ($catalogs as $catalog_id) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null) {
                            $catalog->verify_catalog();
                        }
                    }
                }
                break;
            case 'full_service':
                if (!$catalogs) {
                    $catalogs = self::get_catalogs();
                }

                /* This runs the clean/verify/add in that order */
                $catalog_media_types = array();
                foreach ($catalogs as $catalog_id) {
                    $catalog = self::create_from_id($catalog_id);
                    if ($catalog !== null) {
                        if ($catalog->clean_catalog() < 0 && !in_array($catalog->gather_types, $catalog_media_types)) {
                            $catalog_media_types[] = $catalog->gather_types;
                        }
                        $catalog->verify_catalog();
                        if ($catalog->add_to_catalog() && !in_array($catalog->gather_types, $catalog_media_types)) {
                            $catalog_media_types[] = $catalog->gather_types;
                        }
                    }
                }
                foreach ($catalog_media_types as $catalog_media_type) {
                    if ($catalog_media_type == 'music') {
                        self::clean_empty_albums();
                        Album::update_album_artist();
                    }
                    self::update_catalog_map($catalog_media_type);
                }
                break;
            case 'clean_all_catalogs':
                $catalogs = self::get_catalogs();
                // Intentional break fall-through
            case 'clean_catalog':
                if ($catalogs) {
                    $catalog_media_types = array();
                    foreach ($catalogs as $catalog_id) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null) {
                            if ($catalog->clean_catalog() < 0 && !in_array($catalog->gather_types, $catalog_media_types)) {
                                $catalog_media_types[] = $catalog->gather_types;
                            }
                        }
                    } // end foreach catalogs
                    foreach ($catalog_media_types as $catalog_media_type) {
                        if ($catalog_media_type == 'music') {
                            self::clean_empty_albums();
                            Album::update_album_artist();
                        }
                        self::update_catalog_map($catalog_media_type);
                    }
                    if (in_array('music', $catalog_media_types)) {
                        Artist::update_table_counts();
                        Album::update_table_counts();
                    }
                }
                break;
            case 'update_from':
                $catalog_id  = 0;
                // clean deleted files
                $clean_path = (string)($options['clean_path'] ?? '/');
                if (strlen($clean_path) && $clean_path != '/') {
                    $catalog_id = Catalog_local::get_from_path($clean_path);
                    if (is_int($catalog_id)) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null && $catalog->catalog_type == 'local') {
                            switch ($catalog->gather_types) {
                                case 'podcast':
                                    $type      = 'podcast_episode';
                                    $file_ids  = Catalog::get_ids_from_folder($clean_path, $type);
                                    $className = Podcast_Episode::class;
                                    break;
                                case 'clip':
                                case 'tvshow':
                                case 'movie':
                                case 'personal_video':
                                    $type      = 'video';
                                    $file_ids  = Catalog::get_ids_from_folder($clean_path, $type);
                                    $className = Video::class;
                                    break;
                                case 'music':
                                default:
                                    $type      = 'song';
                                    $file_ids  = Catalog::get_ids_from_folder($clean_path, $type);
                                    $className = Song::class;
                                    break;
                            }
                            $changed = 0;
                            foreach ($file_ids as $file_id) {
                                /** @var Song|Podcast_Episode|Video $className */
                                $media = new $className($file_id);
                                if ($media->file) {
                                    /** @var Catalog_local $catalog */
                                    if ($catalog->clean_file($media->file, $type)) {
                                        $changed++;
                                    }
                                }
                            }
                            if ($changed > 0) {
                                self::update_catalog_map($catalog->gather_types);
                            }
                        }
                    }
                }
                // update_from_tags
                $update_path = (string)($options['update_path'] ?? '/');
                if (strlen($update_path) && $update_path != '/') {
                    if (is_int(Catalog_local::get_from_path($update_path))) {
                        $songs = self::get_ids_from_folder($update_path, 'song');
                        foreach ($songs as $song_id) {
                            self::update_single_item('song', $song_id);
                        }
                    }
                }
                // add new files
                $add_path = (string)($options['add_path'] ?? '/');
                if (strlen($add_path) && $add_path != '/') {
                    $catalog_id = Catalog_local::get_from_path($add_path);
                    if (is_int($catalog_id)) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null && $catalog->add_to_catalog(array('subdirectory' => $add_path))) {
                            self::update_catalog_map($catalog->gather_types);
                        }
                    }
                }

                if ($catalog_id < 1) {
                    AmpError::add(
                        'general',
                        T_("This subdirectory is not inside an existing Catalog. The update can not be processed.")
                    );
                }
                break;
            case 'gather_media_art':
                if (!$catalogs) {
                    $catalogs = self::get_catalogs();
                }

                // Iterate throughout the catalogs and gather as needed
                foreach ($catalogs as $catalog_id) {
                    $catalog = self::create_from_id($catalog_id);
                    if ($catalog !== null) {
                        require Ui::find_template('show_gather_art.inc.php');
                        flush();
                        $catalog->gather_art();
                    }
                }
                break;
            case 'update_all_file_tags':
                $catalogs = self::get_catalogs();
                // Intentional break fall-through
            case 'update_file_tags':
                $write_tags = AmpConfig::get('write_tags', false);
                AmpConfig::set_by_array(
                    ['write_tags' => 'true'],
                    true
                );

                if (!empty($catalogs)) {
                    $songTagWriter = static::getSongTagWriter();
                    set_time_limit(0);
                    foreach ($catalogs as $catalog_id) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null) {
                            $song_ids = $catalog->get_song_ids();
                            foreach ($song_ids as $song_id) {
                                $song = new Song($song_id);
                                $song->format();

                                $songTagWriter->write($song);
                            }
                        }
                    }
                }
                AmpConfig::set_by_array(
                    ['write_tags' => $write_tags],
                    true
                );
                break;
            case 'garbage_collect':
                debug_event(__CLASS__, 'Run Garbage collection', 5);
                static::getCatalogGarbageCollector()->collect();
                $catalog_media_types = array();
                if (!empty($catalogs)) {
                    foreach ($catalogs as $catalog_id) {
                        $catalog = self::create_from_id($catalog_id);
                        if ($catalog !== null && !in_array($catalog->gather_types, $catalog_media_types)) {
                            $catalog_media_types[] = $catalog_media_types;
                        }
                    }
                    foreach ($catalog_media_types as $catalog_media_type) {
                        if ($catalog_media_types == 'music') {
                            self::clean_empty_albums();
                            Album::update_album_artist();
                        }
                        self::update_catalog_map($catalog_media_type);
                    }
                    self::garbage_collect_mapping();
                    self::garbage_collect_filters();
                    self::update_counts();
                }
        }
    }

    /**
     * Get the directory for this file from the catalog and the song info using the sort_pattern
     * takes into account various artists and the alphabet_prefix
     * @param Song $song
     * @param string $sort_pattern
     * @param string|null $base
     * @param string $various_artist
     * @param bool $windowsCompat
     */
    public function sort_find_home($song, $sort_pattern, $base = null, $various_artist = "Various Artists", $windowsCompat = false): ?string
    {
        $home = '';
        if ($base) {
            $home = rtrim($base, "\/");
            $home = rtrim($home, "\\");
        }

        // Create the filename that this file should have
        $album = self::sort_clean_name($song->get_album_fullname(), '%A', $windowsCompat);
        //$artist = self::sort_clean_name($song->get_artist_fullname(), '%a', $windowsCompat);
        $track = self::sort_clean_name($song->track, '%T', $windowsCompat);
        if ((int) $track < 10) {
            $track = '0' . (string) $track;
        }

        $title   = self::sort_clean_name($song->title, '%t', $windowsCompat);
        $year    = self::sort_clean_name($song->year, '%y', $windowsCompat);
        $comment = self::sort_clean_name($song->comment, '%c', $windowsCompat);

        // Do the various check
        $album_object = new Album($song->album);
        $album_object->format();
        $artist = (!empty($album_object->f_artist_name))
            ? self::sort_clean_name($album_object->f_artist_name, '%a', $windowsCompat)
            : $various_artist;
        $disk           = self::sort_clean_name($song->disk, '%d');
        $catalog_number = self::sort_clean_name($album_object->catalog_number, '%C');
        $barcode        = self::sort_clean_name($album_object->barcode, '%b');
        $original_year  = self::sort_clean_name($album_object->original_year, '%Y');
        $release_type   = self::sort_clean_name($album_object->release_type, '%r');
        $release_status = self::sort_clean_name($album_object->release_status, '%R');
        $version        = self::sort_clean_name($album_object->version, '%s');
        $genre          = (!empty($album_object->tags))
            ? Tag::get_display($album_object->tags)
            : '%b';

        // Replace everything we can find
        $replace_array = array('%a', '%A', '%t', '%T', '%y', '%Y', '%c', '%C', '%r', '%R', '%s', '%d', '%g', '%b');
        $content_array = array($artist, $album, $title, $track, $year, $original_year, $comment, $catalog_number, $release_type, $release_status, $version, $disk, $genre, $barcode);
        $sort_pattern  = str_replace($replace_array, $content_array, $sort_pattern);

        // Remove non A-Z0-9 chars
        $sort_pattern = preg_replace("[^\\\/A-Za-z0-9\-\_\ \'\, \(\)]", "_", $sort_pattern);

        // Replace non-critical search patterns
        $post_replace_array = array('%Y', '%c', '%C', '%r', '%R', '%g', '%b', ' []', ' ()');
        $post_content_array = array('', '', '', '', '', '', '', '', '', '');
        $sort_pattern       = str_replace($post_replace_array, $post_content_array, (string)$sort_pattern);

        $home .= "/$sort_pattern";

        // don't send a mismatched file!
        foreach ($replace_array as $replace_string) {
            if (strpos($sort_pattern, $replace_string) !== false) {
                return null;
            }
        }

        return $home;
    }

    /**
     * This is run on every individual element of the search before it is put together
     * It removes / and \ and windows-incompatible characters (if you use -w|--windows)
     * @param string|int|null $string
     * @param string $return
     * @param bool $windowsCompat
     */
    public static function sort_clean_name($string, $return = '', $windowsCompat = false): string
    {
        if (empty($string)) {
            return $return;
        }
        $string = ($windowsCompat)
            ? str_replace(['/', '\\', ':', '*', '<', '>', '"', '|', '?'], '_', (string)$string)
            : str_replace(['/', '\\'], '_', (string)$string);

        return (string)$string;
    }

    /**
     * Migrate an object associate images to a new object
     * @param string $object_type
     * @param int $old_object_id
     * @param int $new_object_id
     * @param int $song_id
     */
    public static function migrate($object_type, $old_object_id, $new_object_id, $song_id): bool
    {
        if ($old_object_id != $new_object_id) {
            debug_event(__CLASS__, "migrate $song_id $object_type: {{$old_object_id}} to {{$new_object_id}}", 4);

            Stats::migrate($object_type, $old_object_id, $new_object_id, $song_id);
            Useractivity::migrate($object_type, $old_object_id, $new_object_id);
            Recommendation::migrate($object_type, $old_object_id);
            self::getShareRepository()->migrate($object_type, $old_object_id, $new_object_id);
            self::getShoutRepository()->migrate($object_type, $old_object_id, $new_object_id);
            Tag::migrate($object_type, $old_object_id, $new_object_id);
            Userflag::migrate($object_type, $old_object_id, $new_object_id);
            Rating::migrate($object_type, $old_object_id, $new_object_id);
            Art::duplicate($object_type, $old_object_id, $new_object_id);
            Playlist::migrate($object_type, $old_object_id, $new_object_id);
            Label::migrate($object_type, $old_object_id, $new_object_id);
            if ($object_type === 'artist') {
                self::getWantedRepository()->migrateArtist($old_object_id, $new_object_id);
            }
            self::getMetadataRepository()->migrate($object_type, $old_object_id, $new_object_id);
            self::getBookmarkRepository()->migrate($object_type, $old_object_id, $new_object_id);
            self::migrate_map($object_type, $old_object_id, $new_object_id);

            return true;
        }

        return false;
    }

    public function supportsType(string $type): bool
    {
        return $this->gather_types === $type;
    }

    /**
     * @deprecated
     */
    private static function getSongRepository(): SongRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated
     */
    protected static function getAlbumRepository(): AlbumRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated
     */
    private static function getCatalogGarbageCollector(): CatalogGarbageCollectorInterface
    {
        global $dic;

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

    /**
     * @deprecated
     */
    private static function getSongTagWriter(): SongTagWriterInterface
    {
        global $dic;

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

    /**
     * @deprecated
     */
    private static function getLabelRepository(): LabelRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated
     */
    private static function getLicenseRepository(): LicenseRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated inject by constructor
     */
    private static function getConfigContainer(): ConfigContainerInterface
    {
        global $dic;

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

    /**
     * @deprecated Inject by constructor
     */
    private static function getUtilityFactory(): UtilityFactoryInterface
    {
        global $dic;

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

    /**
     * @deprecated Inject by constructor
     */
    private static function getUserRepository(): UserRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated Inject by constructor
     */
    private static function getShoutRepository(): ShoutRepositoryInterface
    {
        global $dic;

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

    /**
     * @deprecated Inject by constructor
     */
    private static function getPodcastRepository(): PodcastRepositoryInterface
    {
        global $dic;

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

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

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

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

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

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

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

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

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

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

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

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

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