diff --git a/app/Manager/Bonus.php b/app/Manager/Bonus.php index f4467baca..2f0df2be8 100644 --- a/app/Manager/Bonus.php +++ b/app/Manager/Bonus.php @@ -2,6 +2,8 @@ namespace Gazelle\Manager; +use Gazelle\Enum\UserStatus; + class Bonus extends \Gazelle\Base { final public const CACHE_OPEN_POOL = 'bonus_pool'; // also defined in \Gazelle\Bonus final protected const CACHE_ITEM = 'bonus_item'; @@ -45,11 +47,11 @@ class Bonus extends \Gazelle\Base { } public function flushPriceCache(): void { - $this->items = []; + unset($this->items); self::$cache->delete_value(self::CACHE_ITEM); } - public function getOpenPool(): array { + public function openPoolList(): array { $key = self::CACHE_OPEN_POOL; $pool = self::$cache->get_value($key); if ($pool === false) { @@ -84,13 +86,14 @@ class Bonus extends \Gazelle\Base { self::$db->prepared_query(" SELECT um.ID FROM users_main um - AND um.Enabled = '1' - AND NOT EXISTS ( + WHERE NOT EXISTS ( SELECT 1 FROM user_has_attr uha INNER JOIN user_attr ua ON (ua.ID = uha.UserAttrID AND ua.Name IN ('disable-bonus-points', 'no-fl-gifts')) WHERE uha.UserID = um.ID ) - "); + AND um.Enabled = ? + ", UserStatus::enabled->value + ); return $this->addMultiPoints($points, self::$db->collect('ID')); } @@ -99,14 +102,14 @@ class Bonus extends \Gazelle\Base { SELECT um.ID FROM users_main um INNER JOIN user_last_access ula ON (ula.user_id = um.ID) - AND um.Enabled = '1' - AND NOT EXISTS ( + WHERE NOT EXISTS ( SELECT 1 FROM user_has_attr uha INNER JOIN user_attr ua ON (ua.ID = uha.UserAttrID AND ua.Name IN ('disable-bonus-points', 'no-fl-gifts')) WHERE uha.UserID = um.ID ) + AND um.Enabled = ? AND ula.last_access >= ? - ", $since + ", UserStatus::enabled->value, $since ); return $this->addMultiPoints($points, self::$db->collect('ID')); } @@ -116,14 +119,14 @@ class Bonus extends \Gazelle\Base { SELECT DISTINCT um.ID FROM users_main um INNER JOIN torrents t ON (t.UserID = um.ID) - AND um.Enabled = '1' - AND NOT EXISTS ( + WHERE NOT EXISTS ( SELECT 1 FROM user_has_attr uha INNER JOIN user_attr ua ON (ua.ID = uha.UserAttrID AND ua.Name IN ('disable-bonus-points', 'no-fl-gifts')) WHERE uha.UserID = um.ID ) + AND um.Enabled = ? AND t.created >= ? - ", $since + ", UserStatus::enabled->value, $since ); return $this->addMultiPoints($points, self::$db->collect('ID')); } @@ -133,24 +136,26 @@ class Bonus extends \Gazelle\Base { SELECT DISTINCT um.ID FROM users_main um INNER JOIN xbt_files_users xfu ON (xfu.uid = um.ID) - AND um.Enabled = '1' - AND NOT EXISTS ( + WHERE NOT EXISTS ( SELECT 1 FROM user_has_attr uha INNER JOIN user_attr ua ON (ua.ID = uha.UserAttrID AND ua.Name IN ('disable-bonus-points', 'no-fl-gifts')) WHERE uha.UserID = um.ID ) - AND xfu.active = 1 and xfu.remaining = 0 and xfu.connectable = 1 and timespent > 0 - "); + AND xfu.remaining = 0 + AND xfu.active = 1 + AND um.Enabled = ? + ", UserStatus::enabled->value + ); return $this->addMultiPoints($points, self::$db->collect('ID')); } public function givePoints(\Gazelle\Task|null $task = null): int { //------------------------ Update Bonus Points -------------------------// // calculation: - // Size * (0.0754 + (0.1207 * ln(1 + seedtime)/ (seeders ^ 0.55))) - // Size (convert from bytes to GB) is in torrents - // Seedtime (convert from hours to days) is in xbt_files_history - // Seeders is in torrents_leech_stats + // size (convert from bytes to GB) is in torrents + // seedtime (convert from hours to days) is in xbt_files_history + // seeders is in torrents_leech_stats + // bonus_scale how the torrent size is adjusted according to category self::$db->dropTemporaryTable("bonus_update"); self::$db->prepared_query(" @@ -165,23 +170,29 @@ class Bonus extends \Gazelle\Base { self::$db->prepared_query(" INSERT INTO bonus_update (user_id, delta) SELECT xfu.uid, - sum(bonus_accrual(t.Size, xfh.seedtime, tls.Seeders)) - FROM xbt_files_users AS xfu - INNER JOIN xbt_files_history AS xfh USING (uid, fid) - INNER JOIN users_main AS um ON (um.ID = xfu.uid) - INNER JOIN torrents AS t ON (t.ID = xfu.fid) - INNER JOIN torrents_leech_stats AS tls ON (tls.TorrentID = t.ID) - WHERE xfu.active = 1 - AND xfu.remaining = 0 - AND xfu.mtime > unix_timestamp(now() - INTERVAL 1 HOUR) - AND um.Enabled = '1' - AND NOT EXISTS ( + sum(category_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale)) + FROM ( + SELECT DISTINCT uid, fid + FROM xbt_files_users + WHERE remaining = 0 + AND active = 1 + AND mtime > unix_timestamp(now() - INTERVAL 1 HOUR) + ) xfu + INNER JOIN xbt_files_history xfh USING (uid, fid) + INNER JOIN users_main um ON (um.ID = xfu.uid) + INNER JOIN torrents t ON (t.ID = xfu.fid) + INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID) + INNER JOIN torrents_group tg ON (tg.ID = t.GroupID) + INNER JOIN category c ON (c.category_id = tg.CategoryID) + WHERE NOT EXISTS ( SELECT 1 FROM user_has_attr uha INNER JOIN user_attr ua ON (ua.ID = uha.UserAttrID AND ua.Name IN ('disable-bonus-points')) WHERE uha.UserID = um.ID ) + AND um.Enabled = ? GROUP BY xfu.uid - "); + ", UserStatus::enabled->value + ); self::$db->prepared_query(" SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ "); @@ -189,7 +200,7 @@ class Bonus extends \Gazelle\Base { self::$db->prepared_query(" INSERT INTO user_bonus - (user_id, points) + (user_id, points) SELECT bu.user_id, bu.delta FROM bonus_update bu ON DUPLICATE KEY UPDATE points = points + bu.delta diff --git a/app/User/Bonus.php b/app/User/Bonus.php index 126acd26b..344273c97 100644 --- a/app/User/Bonus.php +++ b/app/User/Bonus.php @@ -3,6 +3,7 @@ namespace Gazelle\User; use Gazelle\BonusPool; +use Gazelle\Util\SortableTableHeader; /** * Note: there is no userHasItem() method to check if a user has bought a @@ -119,6 +120,21 @@ class Bonus extends \Gazelle\BaseUser { return $summary; } + public function heading(): SortableTableHeader { + return new SortableTableHeader('hourlypoints', [ + 'title' => ['dbColumn' => 'title', 'defaultSort' => 'asc', 'text' => 'Title'], + 'size' => ['dbColumn' => 'size', 'defaultSort' => 'desc', 'text' => 'Size'], + 'seeders' => ['dbColumn' => 'seeders', 'defaultSort' => 'desc', 'text' => 'Seeders'], + 'seedtime' => ['dbColumn' => 'seed_time', 'defaultSort' => 'desc', 'text' => 'Duration'], + 'hourlypoints' => ['dbColumn' => 'hourly_points', 'defaultSort' => 'desc', 'text' => 'BP/hour'], + 'dailypoints' => ['dbColumn' => 'daily_points', 'defaultSort' => 'desc', 'text' => 'BP/day'], + 'weeklypoints' => ['dbColumn' => 'weekly_points', 'defaultSort' => 'desc', 'text' => 'BP/week'], + 'monthlypoints' => ['dbColumn' => 'monthly_points', 'defaultSort' => 'desc', 'text' => 'BP/month'], + 'yearlypoints' => ['dbColumn' => 'yearly_points', 'defaultSort' => 'desc', 'text' => 'BP/year'], + 'pointspergb' => ['dbColumn' => 'points_per_gb', 'defaultSort' => 'desc', 'text' => 'BP/GB/year'], + ]); + } + public function history(int $limit, int $offset): array { $page = $offset / $limit; $key = sprintf(self::CACHE_HISTORY, $this->user->id, $page); @@ -494,15 +510,17 @@ class Bonus extends \Gazelle\BaseUser { public function hourlyRate(): float { return (float)self::$db->scalar(" - SELECT sum(bonus_accrual(t.Size, xfh.seedtime, tls.Seeders)) + SELECT sum(category_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale)) FROM ( - SELECT DISTINCT uid,fid + SELECT DISTINCT uid, fid FROM xbt_files_users WHERE active = 1 AND remaining = 0 AND mtime > unix_timestamp(NOW() - INTERVAL 1 HOUR) AND uid = ? ) AS xfu - INNER JOIN xbt_files_history AS xfh USING (uid, fid) - INNER JOIN torrents AS t ON (t.ID = xfu.fid) + INNER JOIN xbt_files_history xfh USING (uid, fid) + INNER JOIN torrents t ON (t.ID = xfu.fid) INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID) + INNER JOIN torrents_group tg ON (tg.ID = t.GroupID) + INNER JOIN category c ON (c.category_id = tg.CategoryID) WHERE xfu.uid = ? ", $this->user->id, $this->user->id ); @@ -510,29 +528,31 @@ class Bonus extends \Gazelle\BaseUser { public function userTotals(): array { $stats = self::$db->rowAssoc(" - SELECT count(*) AS total_torrents, - coalesce(sum(t.Size), 0) AS total_size, - coalesce(sum(bonus_accrual(t.Size, xfh.seedtime, tls.Seeders)), 0) AS hourly_points, - coalesce(sum(bonus_accrual(t.Size, xfh.seedtime + (24 * 1), tls.Seeders)), 0) * (24 * 1) AS daily_points, - coalesce(sum(bonus_accrual(t.Size, xfh.seedtime + (24 * 7), tls.Seeders)), 0) * (24 * 7) AS weekly_points, - coalesce(sum(bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004/12), tls.Seeders)), 0) * (24 * 365.256363004/12) AS monthly_points, - coalesce(sum(bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004), tls.Seeders)), 0) * (24 * 365.256363004) AS yearly_points, + SELECT count(*) AS total_torrents, + coalesce(sum(t.Size), 0) AS total_size, + coalesce(sum(category_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale)), 0) AS hourly_points, + coalesce(sum(future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 1)), 0) AS daily_points, + coalesce(sum(future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 7)), 0) AS weekly_points, + coalesce(sum(future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636 / 12)), 0) AS monthly_points, + coalesce(sum(future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636)), 0) AS yearly_points, if (coalesce(sum(t.Size), 0) = 0, 0, - sum(bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004), tls.Seeders)) * (24 * 365.256363004) - / (sum(t.Size) / (1024*1024*1024)) + sum(future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636)) + / (sum(t.Size) / (1024*1024*1024)) ) AS points_per_gb FROM ( SELECT DISTINCT uid, fid FROM xbt_files_users - WHERE active = 1 + WHERE active = 1 AND remaining = 0 - AND mtime > unix_timestamp(NOW() - INTERVAL 1 HOUR) - AND uid = ? + AND mtime > unix_timestamp(NOW() - INTERVAL 1 HOUR) + AND uid = ? ) AS xfu - INNER JOIN xbt_files_history AS xfh USING (uid, fid) - INNER JOIN torrents AS t ON (t.ID = xfu.fid) + INNER JOIN xbt_files_history xfh USING (uid, fid) + INNER JOIN torrents t ON (t.ID = xfu.fid) INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID) + INNER JOIN torrents_group tg ON (tg.ID = t.GroupID) + INNER JOIN category c ON (c.category_id = tg.CategoryID) WHERE xfu.uid = ? ", $this->user->id, $this->user->id ); @@ -540,37 +560,52 @@ class Bonus extends \Gazelle\BaseUser { return $stats; } - public function seedList(string $orderBy, string $orderWay, int $limit, int $offset): array { + public function seedList( + int $limit, + int $offset, + \Gazelle\Manager\Torrent $torMan = new \Gazelle\Manager\Torrent(), + ): array { + $heading = $this->heading(); self::$db->prepared_query(" SELECT t.ID, + tg.Name AS title, t.Size AS size, GREATEST(tls.Seeders, 1) AS seeders, xfh.seedtime AS seed_time, - bonus_accrual(t.Size, xfh.seedtime, tls.Seeders) AS hourly_points, - bonus_accrual(t.Size, xfh.seedtime + (24 * 1), tls.Seeders) * (24 * 1) AS daily_points, - bonus_accrual(t.Size, xfh.seedtime + (24 * 7), tls.Seeders) * (24 * 7) AS weekly_points, - bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004/12), tls.Seeders) * (24 * 365.256363004/12) AS monthly_points, - bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004), tls.Seeders) * (24 * 365.256363004) AS yearly_points, - bonus_accrual(t.Size, xfh.seedtime + (24 * 365.256363004), tls.Seeders) * (24 * 365.256363004) + category_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale) AS hourly_points, + future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 1) AS daily_points, + future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 7) AS weekly_points, + future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636 / 12) AS monthly_points, + future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636) AS yearly_points, + future_bonus_accrual(t.Size, xfh.seedtime, tls.Seeders, c.bonus_scale, 365.25636) / (t.Size / (1024*1024*1024)) AS points_per_gb FROM ( - SELECT DISTINCT uid,fid FROM xbt_files_users WHERE active=1 AND remaining=0 AND mtime > unix_timestamp(NOW() - INTERVAL 1 HOUR) AND uid = ? + SELECT DISTINCT uid, fid + FROM xbt_files_users + WHERE active = 1 + AND remaining = 0 + AND mtime > unix_timestamp(NOW() - INTERVAL 1 HOUR) + AND uid = ? ) AS xfu - INNER JOIN xbt_files_history AS xfh USING (uid, fid) - INNER JOIN torrents AS t ON (t.ID = xfu.fid) - INNER JOIN torrents_leech_stats AS tls ON (tls.TorrentID = t.ID) + INNER JOIN xbt_files_history xfh USING (uid, fid) + INNER JOIN torrents t ON (t.ID = xfu.fid) + INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID) + INNER JOIN torrents_group tg ON (tg.ID = t.GroupID) + INNER JOIN category c ON (c.category_id = tg.CategoryID) WHERE xfu.uid = ? - ORDER BY $orderBy $orderWay + ORDER BY {$heading->orderBy()} {$heading->dir()} LIMIT ? OFFSET ? ", $this->user->id, $this->user->id, $limit, $offset ); $list = []; foreach (self::$db->to_array('ID', MYSQLI_ASSOC) as $r) { - $r['torrent'] = new \Gazelle\Torrent($r['ID']); - $list[] = $r; + if ($r['ID']) { + $r['torrent'] = $torMan->findById((int)$r['ID']); + $list[] = $r; + } } return $list; } diff --git a/app/Util/Time.php b/app/Util/Time.php index b3a8419e8..0319b33e0 100644 --- a/app/Util/Time.php +++ b/app/Util/Time.php @@ -218,7 +218,11 @@ class Time { return '0s'; } - $interval = [($seconds % 60) . 's']; + $interval = []; + $remainder = $seconds % 60; + if ($remainder) { + $interval[] = "{$remainder}s"; + } $minutes = (int)floor($seconds / 60); if ($minutes >= 60) { @@ -254,7 +258,16 @@ class Time { $interval[] = "{$day}d"; } if ($week) { - $interval[] = "{$week}w"; + if ($week < 52) { + $interval[] = "{$week}w"; + } else { + $year = (int)floor($week / 52); + $week = $week % 52; + if ($week) { + $interval[] = "{$week}w"; + } + $interval[] = "{$year}y"; + } } return implode('', array_slice(array_reverse($interval), 0, 2)); } diff --git a/docs/01-MysqlRoles.txt b/docs/01-MysqlRoles.txt index efa960595..4b0d1fa36 100644 --- a/docs/01-MysqlRoles.txt +++ b/docs/01-MysqlRoles.txt @@ -2,7 +2,7 @@ From: Spine To: Developers Date: 2024-12-07 Subject: Orpheus Development Papers #1 - Mysql Roles -Version: 2 +Version: 3 The default Gazelle installation defines a single Mysql role (with full privileges) and used for everything: the website, Ocelot and Sphinx. If any @@ -61,6 +61,8 @@ GRANT CREATE TEMPORARY TABLES, DELETE, INSERT, SELECT, UPDATE ON `gazelle`.* TO GRANT DROP ON `gazelle`.`drives` TO 'www'@'localhost'; GRANT EXECUTE ON FUNCTION `gazelle`.`binomial_ci` TO 'www'@'localhost'; GRANT EXECUTE ON FUNCTION `gazelle`.`bonus_accrual` TO 'www'@'localhost'; +GRANT EXECUTE ON FUNCTION `gazelle`.`category_bonus_accrual` TO 'www'@'localhost'; +GRANT EXECUTE ON FUNCTION `gazelle`.`future_bonus_accrual` TO 'www'@'localhost'; GRANT SELECT ON `sys`.`schema_unused_indexes` TO 'www'@'localhost'; GRANT SELECT ON `performance_schema`.`table_io_waits_summary_by_index_usage` TO 'www'@'localhost'; GRANT SELECT ON `performance_schema`.`table_io_waits_summary_by_table` TO 'www'@'localhost'; diff --git a/docs/Docker.txt b/docs/Docker.txt index 58785029f..f3273c866 100644 --- a/docs/Docker.txt +++ b/docs/Docker.txt @@ -27,13 +27,11 @@ docker exec -i $MYSQL_CONTAINER sh -c "exec mysql -uroot -p'$PASSWORD'" < all-da docker exec -it $MYSQL_CONTAINER mysql_upgrade -u root -p$PASSWORD # Some custom functions may need to be recreated (the site will error on load) -git grep CREATE FUNCTION misc/phinx/db/migrations -misc/phinx/db/migrations/20200320183228_bonus_accrual_function.php +git grep -l 'CREATE FUNCTION' misc/my-migrations +misc/my-migrations/20180104060449_tables.php +misc/my-migrations/20200320183228_bonus_accrual_function.php -Execute in the mysql client: - CREATE FUNCTION bonus_accrual(Size bigint, Seedtime float, Seeders integer) - RETURNS float DETERMINISTIC NO SQL - RETURN Size / pow(1024, 3) * (0.0433 + (0.07 * ln(1 + Seedtime/24)) / pow(greatest(Seeders, 1), 0.35)); +Execute the function definition statements in the mysql client. memcache -------- diff --git a/misc/docker/web/bootstrap-base.sh b/misc/docker/web/bootstrap-base.sh index 55c1f6d0f..f8bf10011 100755 --- a/misc/docker/web/bootstrap-base.sh +++ b/misc/docker/web/bootstrap-base.sh @@ -68,12 +68,15 @@ GRANT SELECT ON performance_schema.table_io_waits_summary_by_table TO 'ro_$MYSQL GRANT SELECT ON sys.schema_redundant_indexes TO 'ro_$MYSQL_USER'@'%'; GRANT SELECT ON sys.schema_unused_indexes TO 'ro_$MYSQL_USER'@'%'; GRANT SELECT ON sys.x\$schema_flattened_keys TO 'ro_$MYSQL_USER'@'%'; -CREATE FUNCTION IF NOT EXISTS bonus_accrual(Size bigint, Seedtime float, Seeders integer) - RETURNS float DETERMINISTIC NO SQL - RETURN Size / pow(1024, 3) * (0.0433 + (0.07 * ln(1 + Seedtime/24)) / pow(greatest(Seeders, 1), 0.35)); CREATE FUNCTION IF NOT EXISTS binomial_ci(p int, n int) RETURNS float DETERMINISTIC RETURN IF(n = 0,0.0,((p + 1.35336) / n - 1.6452 * SQRT((p * (n-p)) / n + 0.67668) / n) / (1 + 2.7067 / n)); +CREATE FUNCTION IF NOT EXISTS bonus_accrual(Size bigint, Seedtime float, Seeders integer) + RETURNS float DETERMINISTIC NO SQL + RETURN Size / pow(1024, 3) * (0.0433 + (0.07 * ln(1 + Seedtime/24)) / pow(greatest(Seeders, 1), 0.35)); +CREATE FUNCTION IF NOT EXISTS category_bonus_accrual(size bigint, seedtime float, seeders integer, scale float) + RETURNS float DETERMINISTIC NO SQL + RETURN (size / scale) / pow(1024, 3) * (0.0433 + (0.07 * ln(1 + seedtime/24)) / pow(greatest(seeders, 1), 0.35)); EOF ) | mysql -u root -p"$MYSQL_ROOT_PASSWORD" || exit 1 fi diff --git a/misc/my-migrations/20250822000000_category_bonus_scale.php b/misc/my-migrations/20250822000000_category_bonus_scale.php new file mode 100644 index 000000000..34e52851f --- /dev/null +++ b/misc/my-migrations/20250822000000_category_bonus_scale.php @@ -0,0 +1,44 @@ +table('category') + ->addColumn('bonus_scale', 'float', ['default' => '1.0']) + ->save(); + $this->execute(' + CREATE FUNCTION IF NOT EXISTS category_bonus_accrual(size bigint, seedtime float, seeders integer, scale float) + RETURNS float DETERMINISTIC NO SQL + RETURN (size / scale) / pow(1024, 3) * (0.0433 + (0.07 * ln(1 + seedtime/24)) / pow(greatest(seeders, 1), 0.35)) + '); + // 0.0433 * 24 = 1.0392, 0.07 * 24 = 1.68 + $this->execute(' + CREATE FUNCTION IF NOT EXISTS future_bonus_accrual(size bigint, seedtime float, seeders integer, scale float, days float) + RETURNS float DETERMINISTIC NO SQL + RETURN (size / scale) / (1024*1024*1024) + * ( + 1.0392 * days + + 1.68 * ( + (seedtime / 24 + days + 1) * (ln(seedtime / 24 + days + 1) - 1) + - (seedtime / 24 + 1) * (ln(seedtime / 24 + 1) - 1) + ) + / pow(greatest(seeders, 1), 0.35) + ); + '); + } + + public function down(): void { + $this->table('category') + ->removeColumn('bonus_scale') + ->save(); + $this->execute(' + DROP FUNCTION IF EXISTS category_bonus_accrual + '); + $this->execute(' + DROP FUNCTION IF EXISTS future_bonus_accrual + '); + } +} diff --git a/misc/pg-migrations/20250822000000_category_bonus_scale.php b/misc/pg-migrations/20250822000000_category_bonus_scale.php new file mode 100644 index 000000000..2549dbce2 --- /dev/null +++ b/misc/pg-migrations/20250822000000_category_bonus_scale.php @@ -0,0 +1,37 @@ +query(" + drop foreign table if exists relay.category + "); + $this->query(" + import foreign schema " . MYSQL_DB + . " limit to (category) from server relayer into relay; + "); + $this->table('category') + ->addColumn('bonus_scale', 'float', ['default' => '1.0']) + ->save(); + } + + public function down(): void { + $this->query(" + drop foreign table if exists relay.category + "); + $this->query(" + import foreign schema " . MYSQL_DB + . " limit to (category) from server relayer into relay; + "); + $this->table('category') + ->removeColumn('bonus_scale') + ->save(); + } +} diff --git a/sections/bonus/bprates.php b/sections/bonus/bprates.php index 2a5650ff8..aae39eb5b 100644 --- a/sections/bonus/bprates.php +++ b/sections/bonus/bprates.php @@ -6,35 +6,16 @@ declare(strict_types=1); namespace Gazelle; -$page = max(1, (int)($_GET['page'] ?? 1)); -$limit = TORRENTS_PER_PAGE; -$offset = TORRENTS_PER_PAGE * ($page - 1); - -$heading = new Util\SortableTableHeader('hourlypoints', [ - 'size' => ['dbColumn' => 'size', 'defaultSort' => 'desc', 'text' => 'Size'], - 'seeders' => ['dbColumn' => 'seeders', 'defaultSort' => 'desc', 'text' => 'Seeders'], - 'seedtime' => ['dbColumn' => 'seed_time', 'defaultSort' => 'desc', 'text' => 'Duration'], - 'hourlypoints' => ['dbColumn' => 'hourly_points', 'defaultSort' => 'desc', 'text' => 'BP/hour'], - 'dailypoints' => ['dbColumn' => 'daily_points', 'defaultSort' => 'desc', 'text' => 'BP/day'], - 'weeklypoints' => ['dbColumn' => 'weekly_points', 'defaultSort' => 'desc', 'text' => 'BP/week'], - 'monthlypoints' => ['dbColumn' => 'monthly_points', 'defaultSort' => 'desc', 'text' => 'BP/month'], - 'yearlypoints' => ['dbColumn' => 'yearly_points', 'defaultSort' => 'desc', 'text' => 'BP/year'], - 'pointspergb' => ['dbColumn' => 'points_per_gb', 'defaultSort' => 'desc', 'text' => 'BP/GB/year'], -]); - -$userMan = new Manager\User(); if (empty($_GET['userid'])) { $user = $Viewer; - $ownProfile = true; } else { if (!$Viewer->permitted('admin_bp_history')) { Error403::error(); } - $user = $userMan->findById((int)($_GET['userid'] ?? 0)); + $user = new Manager\User()->findById((int)($_GET['userid'] ?? 0)); if (is_null($user)) { Error404::error(); } - $ownProfile = false; } $bonus = new User\Bonus($user); @@ -43,10 +24,9 @@ $paginator = new Util\Paginator(TORRENTS_PER_PAGE, (int)($_GET['page'] ?? 1)); $paginator->setTotal($total['total_torrents']); echo $Twig->render('user/bonus.twig', [ - 'heading' => $heading, - 'list' => $bonus->seedList($heading->orderBy(), $heading->dir(), $paginator->limit(), $paginator->offset()), + 'heading' => $bonus->heading(), + 'list' => $bonus->seedList($paginator->limit(), $paginator->offset()), 'paginator' => $paginator, - 'title' => $ownProfile ? 'Your Bonus Points Rate' : ($user->username() . "'s Bonus Point Rate"), 'total' => $total, 'user' => $user, 'viewer' => $Viewer, diff --git a/sections/bonus/store.php b/sections/bonus/store.php index 7055ef3f1..699f35e70 100644 --- a/sections/bonus/store.php +++ b/sections/bonus/store.php @@ -38,7 +38,7 @@ echo $Twig->render('bonus/store.twig', [ 'bonus' => $bonus, 'discount' => $bonusMan->discount(), 'donate' => $donate, - 'pool' => $bonusMan->getOpenPool(), + 'pool' => $bonusMan->openPoolList(), 'purchase' => $purchase, 'viewer' => $Viewer, ]); diff --git a/templates/user/bonus-history.twig b/templates/user/bonus-history.twig index df6fe0401..8662c9b65 100644 --- a/templates/user/bonus-history.twig +++ b/templates/user/bonus-history.twig @@ -12,39 +12,39 @@
{% if paginator.offset == 0 %}
- {% if pool_total %} - {% for p in pool_summary %} +{% if pool_total %} +{% for p in pool_summary %}

- {%- if self %}You{% else %}{{ user.username }}{% endif %} spent +{% if self %}You{% else %}{{ user.username }}{% endif %} spent {{ p.total|number_format }} bonus points to donate to the {{ p.name }} - {% if date(now) > date(p.until_date) %}ended {% else %}ending in {% endif -%} +{% if date(now) > date(p.until_date) %}ended {% else %}ending in {% endif -%} {{- p.until_date|time_diff }}.

- {% endfor %} - {% endif -%} - {%- if summary.total -%} +{% endfor %} +{% endif %} +{% if summary.total %}

- {%- if self %}You{% else %}{{ user.id|user_url }}{% endif %} spent - {% if pool_total %} a further {% endif -%} {{ summary.total|number_format }} +{% if self %}You{% else %}{{ user.id|user_url }}{% endif %} spent +{% if pool_total %} a further {% endif -%} {{ summary.total|number_format }} bonus points to purchase {{ summary.nr|number_format }} item{{ summary.nr|plural }}.

- {% endif %} - {%- if pool_total and summary.total %} +{% endif %} +{% if pool_total and summary.total %}

That makes a grand total of {{ (pool_total + summary.total)|number_format }} points, - {%- set total = pool_total + summary.total -%} - {%- if total > 500000 %} very - {%- elseif total > 1000000 %} very, very - {%- elseif total > 5000000 %} extremely - {%- elseif total > 10000000 %} exceptionally - {%- endif %} well done!

- {%- endif %} +{% set total = pool_total + summary.total -%} +{% if total > 500000 %} very +{% elseif total > 1000000 %} very, very +{% elseif total > 5000000 %} extremely +{% elseif total > 10000000 %} exceptionally +{% endif %} well done!

+{% endif %}
{% endif %} {% if not history %}

No purchase history

{% else %} - {% if paginator.offset == 0 %} +{% if paginator.offset == 0 %}

Item summary

@@ -53,17 +53,17 @@ - {% set total_item = 0 %} - {% set total_cost = 0 %} - {% for i in item %} +{% set total_item = 0 %} +{% set total_cost = 0 %} +{% for i in item %} - {% set total_item = total_item + i.total %} - {% set total_cost = total_cost + i.cost %} +{% set total_item = total_item + i.total %} +{% set total_cost = total_cost + i.cost %} - {% endfor %} +{% endfor %} @@ -71,10 +71,10 @@
Total Cost
{{ i.title }} {{ i.total|number_format }} {{ i.cost|number_format }}
Total {{ total_item|number_format }}

- {% endif %} +{% endif %}

Purchase Details

- {{ paginator.linkbox|raw }} +{{ paginator.linkbox|raw }} @@ -82,14 +82,14 @@ - {% for h in history %} +{% for h in history %} - {% endfor %} +{% endfor %}
ItemPurchase Date For
{{ h.Title }} {{ h.Price|number_format }} {{ h.PurchaseDate|time_diff }} {% if h.OtherUserID > 0 %}{{ h.OtherUserID|user_url }}{% else %} {% endif %}
{{ paginator.linkbox|raw }} {% endif %} diff --git a/templates/user/bonus.twig b/templates/user/bonus.twig index d3042e77d..9dd982906 100644 --- a/templates/user/bonus.twig +++ b/templates/user/bonus.twig @@ -1,3 +1,10 @@ +{% set title %} +{% if user.id == viewer.id %} +Your Bonus Points Rates +{% else %} +{{ user.username }}'s Bonus Point Rates +{% endif %} +{% endset %} {{ header(title) }}

{{ title }}

@@ -42,16 +49,16 @@ - + - - - - - - - - + + + + + + + + @@ -70,7 +77,7 @@ {% else %} - + {% endfor %} diff --git a/tests/helper.php b/tests/helper.php index c601e6c56..ade4de635 100644 --- a/tests/helper.php +++ b/tests/helper.php @@ -132,6 +132,7 @@ class Helper { array $tagName, int $releaseType = 1 ): \Gazelle\TGroup { + $user->requestContext()->setViewer($user); $tgroup = new \Gazelle\Manager\TGroup()->create( categoryId: (int)new Category()->findIdByName('Music'), releaseType: $releaseType, diff --git a/tests/phpunit/BonusTest.php b/tests/phpunit/BonusTest.php index 4164d4746..dbb8f42f6 100644 --- a/tests/phpunit/BonusTest.php +++ b/tests/phpunit/BonusTest.php @@ -16,8 +16,10 @@ class BonusTest extends TestCase { Helper::removeTGroup($tgroup, current($this->userList)); } } - foreach ($this->userList as $user) { - $user->remove(); + if (isset($this->userList)) { + foreach ($this->userList as $user) { + $user->remove(); + } } } @@ -50,7 +52,10 @@ class BonusTest extends TestCase { $giver->setPoints($startingPoints); $this->assertEquals($startingPoints, $giver->user()->bonusPointsTotal(), 'bonus-set-points'); - $itemList = new Manager\Bonus()->itemList(); + $manager = new Manager\Bonus(); + $manager->flushPriceCache(); + + $itemList = $manager->itemList(); $this->assertArrayHasKey('token-1', $itemList, 'item-token-1'); $token = $giver->item('token-1'); $this->assertArrayHasKey('Price', $token, 'item-price-1'); @@ -134,9 +139,100 @@ class BonusTest extends TestCase { $this->assertTrue($giver->removePoints(1.125), 'bonus-taketh-away'); } + public function testBonusPool(): void { + global $Cache; + $Cache->delete_value("bonus_pool"); + $manager = new Manager\Bonus(); + $this->assertEquals( + [], + $manager->openPoolList(), + 'bonus-open-pool', + ); + } + + public function testAddPoints(): void { + $this->userList = [ + Helper::makeUser('bonusadd.' . randomString(6), 'bonus', enable: false), + Helper::makeUser('bonusadd.' . randomString(6), 'bonus', enable: true), + ]; + // back to the future + DB::DB()->prepared_query(" + INSERT INTO user_last_access (user_id, last_access) values (?, ?) + ", $this->userList[1]->id, date('Y-m-d H:i:s', time() + 10) + ); + + $manager = new Manager\Bonus(); + $this->assertEquals( + 1, + // but not too far + $manager->addActivePoints(23456, date('Y-m-d H:i:s', time() + 5)), + 'bonus-add-active', + ); + $this->assertEquals( + 0, + $manager->addMultiPoints(789, []), + 'bonus-add-no-multi-points', + ); + $this->assertEquals( + 2, + $manager->addMultiPoints( + 12345, + array_map(fn ($u) => $u->id, $this->userList), + ), + 'bonus-add-multi-points', + ); + $this->assertEquals( + 0, + $manager->addUploadPoints(369, date('Y-m-d H:i:s', time() + 1)), + 'bonus-add-upload-points', + ); + $this->assertEquals( + 12345, + $this->userList[0]->flush()->bonusPointsTotal(), + 'bonus-added-user-0', + ); + + $this->tgroupList[] = Helper::makeTGroupMusic( + name: 'bonus add ' . randomString(10), + artistName: [[ARTIST_MAIN], ['phpunit bonus add ' . randomString(12)]], + tagName: ['hard.bop'], + user: $this->userList[1], + ); + $torrent = Helper::makeTorrentMusic( + tgroup: $this->tgroupList[0], + user: $this->userList[1], + title: 'phpunit bonus add ' . randomString(10), + ); + Helper::generateTorrentSeed($torrent, $this->userList[1]); + $this->assertGreaterThan( + 0, + $manager->addSeedPoints(24680), + 'bonus-add-seed-points', + ); + $this->assertEquals( + 12345 + 23456 + 24680, + $this->userList[1]->flush()->bonusPointsTotal(), + 'bonus-added-user-1', + ); + + $this->userList[1]->toggleAttr('no-fl-gifts', true); + $this->assertGreaterThan( + 0, + $manager->addGlobalPoints(7531), + 'bonus-add-global-points', + ); + $this->assertEquals( + 12345 + 23456 + 24680, + $this->userList[1]->flush()->bonusPointsTotal(), + 'bonus-added-no-global', + ); + foreach ($this->userList as $u) { + new User\Bonus($u)->setPoints(0.0); + } + } + public function testUploadReward(): void { $this->userList[] = Helper::makeUser('bonusup.' . randomString(6), 'bonus'); - $this->userList[0]->requestContext()->setViewer($this->userList[0]); $this->tgroupList[] = Helper::makeTGroupMusic( name: 'bonus ' . randomString(10), artistName: [[ARTIST_MAIN], ['phpunit bonus ' . randomString(12)]], diff --git a/tests/phpunit/RequestTest.php b/tests/phpunit/RequestTest.php index 4dcce7a37..fefeff9b3 100644 --- a/tests/phpunit/RequestTest.php +++ b/tests/phpunit/RequestTest.php @@ -262,8 +262,9 @@ class RequestTest extends TestCase { // race condition between requests.LastPostTime and requests.created that would be // difficult to remove without adding a lot of complications to the code. $this->assertFalse($this->request->hasNewVote(), 'request-no-new-vote'); - Helper::sleepTick(); // to ensure lastVoteDate() > created() + // add some bounty + Helper::sleepTick(); // to ensure lastVoteDate() > created() $this->assertTrue($this->request->vote($user, $bounty), 'request-more-bounty'); $this->assertTrue($this->request->flush()->hasNewVote(), 'request-has-new-vote'); $this->assertEquals(2, $this->request->userVotedTotal(), 'request-total-voted'); diff --git a/tests/phpunit/Util/TimeTest.php b/tests/phpunit/Util/TimeTest.php index d31da8937..57ae8953d 100644 --- a/tests/phpunit/Util/TimeTest.php +++ b/tests/phpunit/Util/TimeTest.php @@ -78,4 +78,34 @@ class TimeTest extends TestCase { 'time-convert-span' ); } + + public static function providerSeconds(): array { + $hour = 3600; + $day = $hour * 24; + $week = $day * 7; + $year = $week * 52; + return [ + [ -1, '0s'], + [ 0, '0s'], + [ 1, '1s'], + [ 1, '1s'], + [ 60, '1m'], + [ 119, '1m59s'], + [ 3599, '59m59s'], + [ $hour * 19, '19h'], + [ $hour + 62, '1h1m'], + [ $day * 4, '4d'], + [ $week + 61, '1w1m'], + [$year + $hour * 3, '1y3h'], + ]; + } + + #[DataProvider('providerSeconds')] + public function testConvertSeconds(int $seconds, string $expected): void { + $this->assertEquals( + $expected, + Time::convertSeconds($seconds), + "time-seconds-$seconds" + ); + } }
Torrent{{ heading.emit('title')|raw }} {{ heading.emit('size')|raw }}{{ heading.emit('seeders')|raw }}{{ heading.emit('seedtime')|raw }}{{ heading.emit('hourlypoints')|raw }}{{ heading.emit('dailypoints')|raw }}{{ heading.emit('weeklypoints')|raw }}{{ heading.emit('monthlypoints')|raw }}{{ heading.emit('yearlypoints')|raw }}{{ heading.emit('pointspergb')|raw }}{{ heading.emit('seeders')|raw }}{{ heading.emit('seedtime')|raw }}{{ heading.emit('hourlypoints')|raw }}{{ heading.emit('dailypoints')|raw }}{{ heading.emit('weeklypoints')|raw }}{{ heading.emit('monthlypoints')|raw }}{{ heading.emit('yearlypoints')|raw }}{{ heading.emit('pointspergb')|raw }}
No torrents being seeded currentlyNo torrents being seeded currently