Handle unseeded and never-seeded uploads in a sane manner

This commit is contained in:
Spine
2023-03-11 23:10:19 +00:00
parent c7a7375369
commit 77ebf18b7c
41 changed files with 1852 additions and 221 deletions

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged --verbose
yarn lint-staged -q

View File

@@ -246,7 +246,7 @@ class Debug {
}
//Lets not be repetitive
if (($Tracer[$Steps]['function'] == 'include' || $Tracer[$Steps]['function'] == 'require' ) && isset($Tracer[$Steps]['args'][0]) && $Tracer[$Steps]['args'][0] == $File) {
if (isset($Tracer[$Steps]) && ($Tracer[$Steps]['function'] == 'include' || $Tracer[$Steps]['function'] == 'require' ) && isset($Tracer[$Steps]['args'][0]) && $Tracer[$Steps]['args'][0] == $File) {
unset($Tracer[$Steps]['args']);
}

View File

@@ -111,10 +111,6 @@ class PM extends Base {
return $this->info()['subject'];
}
public function body(): string {
return $this->info()['body'];
}
public function isReadable(): bool {
return in_array($this->user->id(), $this->info()['sender_list'])
|| in_array($this->user->id(), $this->recipientList());

View File

@@ -1,15 +0,0 @@
<?php
namespace Gazelle\Schedule\Tasks;
class DeleteNeverSeededTorrents extends \Gazelle\Schedule\Task {
public function run(): void {
$torrents = new \Gazelle\Torrent\Reaper;
$deleted = $torrents->deleteDeadTorrents(false, true);
foreach ($deleted as $id) {
$this->debug("Deleted torrent $id", $id);
$this->processed++;
}
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace Gazelle\Schedule\Tasks;
class DeleteUnseededTorrents extends \Gazelle\Schedule\Task {
public function run(): void {
$torrents = new \Gazelle\Torrent\Reaper;
$deleted = $torrents->deleteDeadTorrents(true, false);
foreach ($deleted as $id) {
$this->debug("Deleted torrent $id", $id);
$this->processed++;
}
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace Gazelle\Schedule\Tasks;
class NotifyNonseedingUploaders extends \Gazelle\Schedule\Task {
public function run(): void {
// Send warnings to uploaders of torrents that will be deleted this week
self::$db->prepared_query("
SELECT
t.ID,
t.GroupID,
tg.Name,
t.Format,
t.Encoding,
t.UserID
FROM torrents AS t
INNER JOIN torrents_leech_stats AS tls ON (tls.TorrentID = t.ID)
INNER JOIN torrents_group AS tg ON (tg.ID = t.GroupID)
INNER JOIN users_info AS u ON (u.UserID = t.UserID)
WHERE tls.last_action < NOW() - INTERVAL 20 DAY
AND tls.last_action != 0
AND u.UnseededAlerts = '1'
ORDER BY tls.last_action ASC"
);
$torrentIDs = self::$db->to_array();
$torrentAlerts = [];
foreach ($torrentIDs as $torrentID) {
[$id, $groupID, $name, $format, $encoding, $userID] = $torrentID;
$userID = (int)$userID;
if (!array_key_exists($userID, $torrentAlerts)) {
$torrentAlerts[$userID] = ['Count' => 0, 'Msg' => ''];
}
$artistName = \Artists::display_artists(\Artists::get_artist($groupID), false, false, false);
if ($artistName) {
$name = "$artistName - $name";
}
if ($format && $encoding) {
$name .= " [$format / $encoding]";
}
$torrentAlerts[$userID]['Msg'] .= "\n[url=torrents.php?torrentid=$id]".$name."[/url]";
$torrentAlerts[$userID]['Count']++;
$this->processed++;
}
$userMan = new \Gazelle\Manager\User;
foreach ($torrentAlerts as $userID => $messageInfo) {
$userMan->sendPM($userID, 0,
'Unseeded torrent notification',
$messageInfo['Count'] . " of your uploads will be deleted for inactivity soon. Unseeded torrents are deleted after 4 weeks. If you still have the files, you can seed your uploads by ensuring the torrents are in your client and that they aren't stopped. You can view the time that a torrent has been unseeded by clicking on the torrent description line and looking for the \"Last active\" time. For more information, please go [url=wiki.php?action=article&amp;id=77]here[/url].\n\nThe following torrent".plural($messageInfo['Count']).' will be removed for inactivity:'.$messageInfo['Msg']."\n\nIf you no longer wish to receive these notifications, please disable them in your profile settings."
);
$this->debug("Warning user $userID about {$messageInfo['Count']} torrents", (int)$userID);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Gazelle\Schedule\Tasks;
class Reaper extends \Gazelle\Schedule\Task {
public function run(): void {
$reaper = new \Gazelle\Torrent\Reaper(new \Gazelle\Manager\Torrent, new \Gazelle\Manager\User);
$this->processed = 0;
if (REAPER_TASK_CLAIM) {
$reaper->claim();
}
if (REAPER_TASK_NOTIFY) {
$this->processed += $reaper->notify();
}
if (REAPER_TASK_REMOVE_UNSEEDED) {
$this->processed += $reaper->removeUnseeded();
}
if (REAPER_TASK_REMOVE_NEVER_SEEDED) {
$this->processed += $reaper->removeNeverSeeded();
}
}
}

View File

@@ -419,6 +419,16 @@ class Torrent extends TorrentAbstract {
$manager->softDelete(SQLDB, 'torrents_lossyweb_approved', [['TorrentID', $this->id]]);
$manager->softDelete(SQLDB, 'torrents_missing_lineage', [['TorrentID', $this->id]]);
self::$db->prepared_query("
DELETE FROM torrent_unseeded WHERE torrent_id = ?
", $this->id
);
self::$db->prepared_query("
DELETE FROM torrent_unseeded_claim WHERE torrent_id = ?
", $this->id
);
self::$db->prepared_query("
INSERT INTO user_torrent_remove
(user_id, torrent_id)

View File

@@ -3,105 +3,587 @@
namespace Gazelle\Torrent;
class Reaper extends \Gazelle\Base {
public function __construct(
protected \Gazelle\Manager\Torrent $torMan,
protected \Gazelle\Manager\User $userMan,
) {}
public function deleteDeadTorrents(bool $unseeded, bool $neverSeeded) {
if (!$unseeded && !$neverSeeded) {
/**
* Send notifications of all the possible upload states. Processing the
* initial states of unseeded and never seeded uploads is what puts
* torrents onto the conveyor belt towards revival... or the reaper.
*
* @return int total number of uploads processed
*/
public function notify(): int {
// move uploads along the conveyor belt
return $this->process($this->initialUnseededList(), ReaperState::UNSEEDED, ReaperNotify::INITIAL)
+ $this->process($this->initialNeverSeededList(), ReaperState::NEVER, ReaperNotify::INITIAL)
+ $this->process($this->finalUnseededList(), ReaperState::UNSEEDED, ReaperNotify::FINAL)
+ $this->process($this->finalNeverSeededList(), ReaperState::NEVER, ReaperNotify::FINAL);
}
/**
* Send notifications (to the users who wish to receive them) regarding
* uploads that are in various states of unseededness. Initial notifications
* kick off the timer that eventually lead to a torrent being reaped.
*/
public function process(array $userList, ReaperState $state, ReaperNotify $notify): int {
$processed = 0;
foreach ($userList as $userId => $ids) {
$user = $this->userMan->findById($userId);
if ($user?->isEnabled()) {
$this->notifySeeder($user, $ids, $state, $notify);
}
$processed += count($ids);
}
return $processed;
}
/**
* Send a PM to a seeder listing their unseeded items
*
* @return int PM conversation id
*/
public function notifySeeder(\Gazelle\User $user, array $ids, ReaperState $state, ReaperNotify $notify): ?int {
if ($user->hasAttr($state->notifyAttr())) {
// No conversation will be created as they didn't ask for one
return null;
}
$never = $state === ReaperState::NEVER;
$final = $notify === ReaperNotify::FINAL;
$total = count($ids);
$subject = ($never ? "You have " : "There " . ($total > 1 ? 'are' : 'is') . " ")
. article($total, $never ? 'a' : 'an') // "a non-seeded" versus "an unseeded"
. ($never ? " non-seeded new upload" : " unseeded upload")
. plural($total)
. ($final ? " scheduled for deletion very soon" : " to rescue");
return $this->userMan->sendPM($user->id(), 0, $subject,
self::$twig->render('notification/unseeded.twig', [
'final' => $final,
'never' => $never,
'list' => $ids,
'user' => $user,
])
);
}
public function initialNeverSeededList(): array {
return $this->initialList(
cond: [
'tls.last_action IS NULL',
't.Time < now() - INTERVAL ? HOUR', // interval
],
interval: NOTIFY_NEVER_SEEDED_INITIAL_HOUR,
state: ReaperState::NEVER,
);
// Unlike unseeded uploads, there's nobody else to contact to see if
// they can help revive them, so the game stops here.
}
public function initialUnseededList(): array {
$list = $this->initialList(
cond: [
'tls.last_action < now() - INTERVAL ? HOUR', // interval
// TODO: We do not want to spam people who have voluntarily unseeded their redundant V2 uploads.
// We need to nuke these V2 torrents and afterwards the following condition may be removed.
"NOT (t.Format = 'MP3' AND t.Encoding = 'V2')",
],
interval: NOTIFY_UNSEEDED_INITIAL_HOUR,
state: ReaperState::UNSEEDED,
);
if (!$list) {
self::$db->commit();
return [];
}
$criteria = [];
if ($unseeded) {
$criteria[] = '(tls.last_action IS NOT NULL AND tls.last_action < now() - INTERVAL 28 DAY)';
// The community can do something about these, find out who snatched them.
$args = [];
foreach ($list as $torrentIds) {
array_push($args, ...$torrentIds);
}
if ($neverSeeded) {
$criteria[] = '(tls.last_action IS NULL AND t.Time < now() - INTERVAL 2 DAY)';
}
$criteria = implode(' OR ', $criteria);
self::$db->prepared_query("
SELECT t.ID
FROM torrents AS t
INNER JOIN torrents_leech_stats AS tls ON (tls.TorrentID = t.ID)
WHERE $criteria
LIMIT 8000
");
$torrents = self::$db->collect('ID');
SELECT xs.uid AS user_id,
group_concat(DISTINCT xs.fid ORDER BY xs.fid) AS ids
FROM xbt_snatched xs
INNER JOIN torrents t ON (t.ID = xs.fid AND t.UserID != xs.uid)
INNER JOIN users_main um ON (um.ID = xs.uid)
LEFT JOIN torrent_unseeded_claim tuc ON (tuc.user_id = xs.uid AND tuc.torrent_id = xs.fid)
WHERE tuc.user_id IS NULL
AND um.Enabled = '1'
AND t.ID IN (" . placeholders($args) . ")
GROUP BY xs.uid
ORDER BY xs.uid
", ...$args
);
$logEntries = $deleteNotes = [];
$torMan = new \Gazelle\Manager\Torrent;
$i = 0;
foreach ($torrents as $id) {
$torrent = $torMan->findById($id);
if (is_null($torrent)) {
continue;
// Send an alert to each snatcher listing all the uploads that they could reseed
$snatchList = $this->expand(NOTIFY_REAPER_MAX_PER_USER, self::$db->to_array(false, MYSQLI_NUM, false));
foreach ($snatchList as $userId => $torrentIds) {
$user = $this->userMan->findById($userId);
// cannot say !$user?->hasAttr() because !null is true
if ($user && !$user->hasAttr(ReaperState::UNSEEDED->notifyAttr())) {
$this->notifySnatcher($user, $torrentIds);
}
[$success, $message] = $torrent->remove(0, 'inactivity (unseeded)');
if (!$success) {
continue;
}
$infohash = strtoupper($torrent->infohash());
$name = $torrent->name();
$userId = $torrent->uploaderId();
$log = "Torrent $id ($name) ($infohash) was deleted for inactivity (unseeded)";
$logEntries[] = $log;
if (!array_key_exists($userId, $deleteNotes)) {
$deleteNotes[$userId] = ['Count' => 0, 'Msg' => ''];
}
$deleteNotes[$userId]['Msg'] .= sprintf("\n[url=torrents.php?id=%s]%s[/url]", $torrent->groupId(), $name);
$deleteNotes[$userId]['Count']++;
++$i;
}
$userMan = new \Gazelle\Manager\User;
foreach ($deleteNotes as $userId => $messageInfo) {
$singular = (($messageInfo['Count'] == 1) ? true : false);
$userMan->sendPM( $userId, 0,
$messageInfo['Count'].' of your torrents '.($singular ? 'has' : 'have').' been deleted for inactivity',
($singular ? 'One' : 'Some').' of your uploads '.($singular ? 'has' : 'have').' been deleted for being unseeded. Since '.($singular ? 'it' : 'they').' didn\'t break any rules (we hope), please feel free to re-upload '.($singular ? 'it' : 'them').".\n\nThe following torrent".($singular ? ' was' : 's were').' deleted:'.$messageInfo['Msg']
// and open reseed claims for them
$args = [];
foreach ($snatchList as $userId => $torrentIds) {
foreach ($torrentIds as $torrentId) {
array_push($args, $userId, $torrentId);
}
}
if ($args) {
self::$db->prepared_query("
INSERT INTO torrent_unseeded_claim (user_id, torrent_id) VALUES "
. placeholders(array_fill(0, (int)(count($args) / 2), true), "(?,?)") , ...$args
);
}
unset($deleteNotes);
self::$db->commit();
return $list;
}
if (count($logEntries) > 0) {
$chunks = array_chunk($logEntries, 100);
foreach ($chunks as $messages) {
/**
* Send a PM to a snatcher with their unseeded uploads.
* NB: A message is sent only on the initial phase. On the final round,
* messages are sent only to the seeders.
*
* @return int PM conversation id
*/
public function notifySnatcher(\Gazelle\User $user, array $ids): int {
$total = count($ids);
return $this->userMan->sendPM($user->id(), 0,
"You have " . article($total, 'an') . " unseeded snatch" . plural($total, 'es') . ' to save',
self::$twig->render('notification/unseeded-snatch.twig', [
'list' => $ids,
'user' => $user,
])
);
}
/**
* Return a hash of user id/torrent ids that have never been/are no longer announced.
* The query self-limits the number of ids returned: there may be more, but they
* will be handled in a subsequent run. This is to help prevent users being swamped
* with hundreds of notifications after the reaper task is deactivated for an extended
* period of time.
*
* Returns list of [user id:torrent ids] pairs to process
*/
public function initialList(array $cond, int $interval, ReaperState $state): array {
$condition = implode(' AND ', $cond);
self::$db->prepared_query("
SELECT t.UserID AS user_id,
group_concat(t.ID ORDER BY t.ID) AS ids
FROM torrents t
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID)
LEFT JOIN torrent_unseeded tu ON (tu.torrent_id = t.ID)
WHERE $condition
AND tu.torrent_id IS NULL
GROUP BY t.UserID
LIMIT ?
", $interval, NOTIFY_REAPER_MAX_NOTIFICATION
);
$initial = $this->expand(NOTIFY_REAPER_MAX_PER_USER, self::$db->to_array(false, MYSQLI_NUM, false));
// We have already limited the number of users visited. We don't, however, know
// if we have received more uploads than we care to handle. We go through what
// the net caught and if there is more than the maximum then the rest are
// discarded. We may not even notify as many as NOTIFY_REAPER_MAX_NOTIFICATION!
// But there will always be a next time.
$torrentIds = [];
$result = [];
$limit = $state === ReaperState::NEVER ? MAX_NEVER_SEEDED_PER_RUN : MAX_UNSEEDED_PER_RUN;
foreach ($initial as $userId => $ids) {
$result[$userId] = $ids;
array_push($torrentIds, ...$ids);
if (count($torrentIds) > $limit) {
break;
}
}
if ($torrentIds) {
// Register the uploads that need to be reseeded, so that another notification
// can be sent later and then eventually reap what's left afterwards.
self::$db->prepared_query("
INSERT INTO torrent_unseeded (torrent_id, state) VALUES "
. placeholders($torrentIds, "(?,'" . $state->value . "')") , ...$torrentIds
);
}
return $result;
}
public function finalNeverSeededList(): array {
return $this->finalList(
state: ReaperState::NEVER,
interval: NOTIFY_NEVER_SEEDED_FINAL_HOUR,
);
}
public function finalUnseededList(): array {
return $this->finalList(
state: ReaperState::UNSEEDED,
interval: NOTIFY_UNSEEDED_FINAL_HOUR,
);
}
/**
* Return a hash of user/torrent ids that were not/no longer announced
* and an unseeded notification has already been issued.
*
* Returns a list of [user id:torrent ids] pairs to process
*/
public function finalList(ReaperState $state, int $interval): array {
// get the notifications to perform
self::$db->begin_transaction();
self::$db->prepared_query("
SELECT t.UserID AS user_id,
group_concat(t.ID ORDER BY t.ID) AS ids
FROM torrents t
INNER JOIN torrent_unseeded tu ON (tu.torrent_id = t.ID)
WHERE tu.unseeded_date < now() - INTERVAL ? HOUR
AND tu.notify = ?
AND tu.state = ?
GROUP BY t.UserID
", $interval, ReaperNotify::INITIAL->value, $state->value
);
$final = $this->expand(NOTIFY_REAPER_MAX_PER_USER, self::$db->to_array(false, MYSQLI_NUM, false));
$limit = $state === ReaperState::NEVER ? MAX_NEVER_SEEDED_PER_RUN : MAX_UNSEEDED_PER_RUN;
$torrentIds = [];
$result = [];
foreach ($final as $userId => $ids) {
$result[$userId] = $ids;
array_push($torrentIds, ...$ids);
if (count($torrentIds) > $limit) {
break;
}
}
if (!$torrentIds) {
self::$db->commit();
return [];
}
// mark the entries as having generated the second warning
self::$db->prepared_query("
UPDATE torrent_unseeded SET
notify = ?
WHERE torrent_id IN (" . placeholders($torrentIds) . ")
", ReaperNotify::FINAL->value, ...$torrentIds
);
self::$db->commit();
return $result;
}
public function removeNeverSeeded(): int {
return $this->remove(
reaperList: $this->reaperList(ReaperState::NEVER, REMOVE_NEVER_SEEDED_HOUR),
reason: 'inactivity (never seeded)',
template: 'notification/removed-never-seeded.twig',
);
}
public function removeUnseeded(): int {
return $this->remove(
reaperList: $this->reaperList(ReaperState::UNSEEDED, REMOVE_UNSEEDED_HOUR),
reason: 'inactivity (unseeded)',
template: 'notification/removed-unseeded.twig',
);
}
/**
* Once we have listed some torrents in the torrent_unseeded table, we
* no longer need to refer to the original torrent upload date or last
* action date. That they have been here for enough time is sufficient
* grounds to reap them.
*
* Return a hash of user id/torrent ids that are no longer/have never
* been announced and must be removed.
*/
public function reaperList(ReaperState $state, int $interval): array {
self::$db->prepared_query("
SELECT t.UserID AS user_id,
group_concat(t.ID ORDER BY t.ID) AS ids
FROM torrents t
INNER JOIN torrent_unseeded tu ON (tu.torrent_id = t.ID)
WHERE tu.unseeded_date < now() - INTERVAL ? HOUR
AND tu.notify = ?
AND tu.state = ?
GROUP BY t.UserID
", $interval, ReaperNotify::FINAL->value, $state->value
);
return $this->expand(NOTIFY_REAPER_MAX_PER_USER, self::$db->to_array(false, MYSQLI_NUM, false));
}
/**
* we have a list of users with lists of torrents to remove. Let's do this!
*/
protected function remove(array $reaperList, string $reason, string $template): int {
$removed = 0;
$userList = [];
foreach ($reaperList as $userId => $torrentIds) {
$notes = [];
foreach ($torrentIds as $torrentId) {
$torrent = $this->torMan->findById($torrentId);
if (is_null($torrent)) {
continue;
}
// get the snatchers of this torrent
self::$db->prepared_query("
INSERT INTO log (Message, Time)
VALUES " . placeholders($messages, '(?, now())')
, ...$messages
SELECT DISTINCT xs.uid
FROM xbt_snatched xs
WHERE xs.fid = ?
AND xs.uid != ?
", $torrentId, $torrent->uploaderId()
);
foreach (self::$db->collect(0, false) as $snatcherId) {
$snatcherId = (int)$snatcherId;
if (!isset($userList[$snatcherId])) {
$userList[$snatcherId] = [];
}
$userList[$snatcherId][] = $torrent->group();
}
// grab the fields we want to use in the report before we blow the torrent away
$note = [
'id' => $torrent->id(),
'infohash' => $torrent->infohash(),
'name' => $torrent->name(),
'tgroup' => $torrent->group(),
];
[$success, $message] = $torrent->remove($userId, $reason, -1);
if ($success) {
$removed++;
$notes[] = $note;
}
}
if ($notes) {
self::$db->prepared_query("
INSERT INTO log (Message) VALUES " . placeholders($notes, '(?)'),
...array_map(fn($t) => "Torrent {$t['id']} ({$t['name']}) ({$t['infohash']}) was deleted for $reason", $notes
)
);
$total = count($notes);
$user = $this->userMan->findById($userId);
if ($user?->isEnabled()) {
$this->userMan->sendPM($userId, 0,
"$total of your uploads " . ($total == 1 ? 'has' : 'have') . " been deleted for $reason",
self::$twig->render($template, [
'notes' => $notes,
'user' => $user,
])
);
}
}
}
// now inform the snatchers of all the torrents that were reaped during this run
foreach ($userList as $userId => $torrentList) {
$user = $this->userMan->findById($userId);
if ($user?->isEnabled()) {
$total = count($torrentList);
$this->userMan->sendPM($userId, 0,
"$total of your snatches " . ($total == 1 ? 'was' : 'were') . " deleted for inactivity",
self::$twig->render('notification/removed-unseeded-snatch.twig', [
'list' => $torrentList,
'user' => $user,
])
);
}
}
return $removed;
}
public function expand(int $limit, array $list): array {
$result = [];
foreach ($list as [$userId, $ids]) {
$result[$userId] = array_map('intval', array_slice(explode(',', $ids), 0, $limit));
}
return $result;
}
/**
*
* @return array of [torrent_id, user_id, points]
*/
public function claim(): array {
self::$db->begin_transaction();
// Remove claims from uploads that the owner has begun to reseed
self::$db->prepared_query("
SELECT SimilarID
FROM artists_similar_scores
WHERE Score <= 0");
$similarIDs = self::$db->collect('SimilarID');
DELETE tu, tuc
FROM torrent_unseeded tu
iNNER JOIN xbt_files_users xfu ON (xfu.fid = tu.torrent_id)
INNER JOIN torrents t ON (t.ID = xfu.fid AND t.UserID = xfu.uid)
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = xfu.fid)
LEFT JOIN torrent_unseeded_claim tuc USING (torrent_id)
WHERE tu.unseeded_date < tls.last_action
AND tuc.claim_date IS NULL
AND xfu.remaining = 0
AND xfu.timespent > 0
");
if ($similarIDs) {
self::$db->prepared_query("
DELETE FROM artists_similar
WHERE SimilarID IN (" . placeholders($similarIDs, '(?)') . ")
", ...$similarIDs);
$placeholders = placeholders($similarIDs);
self::$db->prepared_query("
DELETE FROM artists_similar_scores
WHERE SimilarID IN ($placeholders)
", ...$similarIDs);
self::$db->prepared_query("
DELETE FROM artists_similar_votes
WHERE SimilarID IN ($placeholders)
", ...$similarIDs);
// Get the snatchers who are seeding uploads for which a claim is open
self::$db->prepared_query("
SELECT xfu.fid as torrent_id,
xfu.timespent,
xfu.uid AS user_id
FROM xbt_files_users xfu
INNER JOIN torrents t ON (t.ID = xfu.fid)
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = xfu.fid)
INNER JOIN torrent_unseeded tu ON (tu.torrent_id = xfu.fid)
LEFT JOIN torrent_unseeded_claim tuc ON (tuc.torrent_id = xfu.fid AND tuc.user_id = xfu.uid)
WHERE tuc.claim_date IS NULL
AND tu.unseeded_date < tls.last_action
AND NOT EXISTS (
SELECT 1
FROM torrent_unseeded_claim tuc_prev
WHERE tuc_prev.claim_date IS NOT NULL
AND tuc_prev.torrent_id = tuc.torrent_id
AND tuc_prev.user_id = tuc.user_id
)
AND xfu.active = 1
AND xfu.remaining = 0
ORDER BY torrent_id, xfu.timespent DESC
");
$seederList = self::$db->to_array(false, MYSQLI_NUM, false);
if (empty($seederList)) {
self::$db->commit();
return [];
}
return array_keys($torrents);
// The first user (who has been seeding the longest) is rewarded, as
// well as any other user who reseeds within 15 minutes afterwards
$win = [];
$saved = [];
$prevTorrentId = false;
$longestTimeSpent = false;
foreach ($seederList as [$torrentId, $timeSpent, $userId]) {
if ($longestTimeSpent === false) {
$longestTimeSpent = $timeSpent;
}
if ($torrentId !== $prevTorrentId) {
$saved[] = $torrentId;
}
if ($timeSpent + 900 >= $longestTimeSpent) {
$torrent = $this->torMan->findById($torrentId);
$user = $this->userMan->findById($userId);
if (is_null($torrent) || is_null($user)) {
continue;
}
$win[] = [
$torrentId,
$userId,
$this->notifyWinner($torrent, new \Gazelle\User\Bonus($user)),
];
}
$prevTorrentid = $torrentId;
}
if ($saved) {
self::$db->prepared_query("
DELETE FROM torrent_unseeded
WHERE torrent_id IN (" . placeholders($saved) . ")
", ...$saved
);
// revoke all the remaining claims other seeders had on these uploads
// NB: It might be worth keeping these longer for subsequent
// arbitration, as it represents a snapshot that cannot be recreated
// once the swarm population changes.
self::$db->prepared_query("
DELETE FROM torrent_unseeded_claim
WHERE claim_date IS NULL
AND torrent_id IN (" . placeholders($saved) . ")
", ...$saved
);
}
self::$db->commit();
return $win;
}
/**
* Send a PM to the snatchers to thank them for reseeding an upload.
* Clear out all the other claims.
*/
public function notifyWinner(\Gazelle\Torrent $torrent, \Gazelle\User\Bonus $bonus): float {
self::$db->prepared_query("
UPDATE torrent_unseeded_claim SET
claim_date = now()
WHERE claim_date IS NULL
AND torrent_id = ?
AND user_id = ?
", $torrent->id(), $bonus->user()->id()
);
$points = REAPER_RESEED_REWARD_FACTOR * $bonus->torrentValue($torrent);
$bonus->addPoints($points);
$bonus->user()->addStaffNote("Awarded {$points} BP for reseeding [pl]{$torrent->id()}[/pl]")->modify();
$this->userMan->sendPM($bonus->user()->id(), 0,
"Thank you for reseeding {$torrent->group()->name()}!",
self::$twig->render('notification/reseed.twig', [
'points' => $points,
'torrent' => $torrent,
'user' => $bonus->user(),
])
);
return $points;
}
public function claimStats(): array {
return array_map('intval', self::$db->rowAssoc("
SELECT coalesce(sum(claim_date IS NULL), 0) AS open,
coalesce(sum(claim_date IS NOT NULL), 0) AS claimed
FROM torrent_unseeded_claim
"));
}
/**
* Some statistics regarding the state of unseeded/never seeded
* uploads.
*/
public function stats(): array {
$stats = [
'never_seeded_initial' => 0,
'unseeded_initial' => 0,
'never_seeded_final' => 0,
'unseeded_final' => 0,
];
self::$db->prepared_query("
SELECT notify,
state,
count(*) as total
FROM torrent_unseeded
GROUP BY notify, state
");
$results = self::$db->to_array(false, MYSQLI_ASSOC, false);
foreach($results as $r) {
if ($r['state'] == ReaperState::UNSEEDED->value && $r['notify'] == ReaperNotify::INITIAL->value) {
$stats['unseeded_initial'] = $r['total'];
}
elseif ($r['state'] == ReaperState::UNSEEDED->value && $r['notify'] == ReaperNotify::FINAL->value) {
$stats['unseeded_final'] = $r['total'];
}
elseif ($r['state'] == ReaperState::NEVER->value && $r['notify'] == ReaperNotify::INITIAL->value) {
$stats['never_seeded_initial'] = $r['total'];
}
else {
$stats['never_seeded_final'] = $r['total'];
}
}
return $stats;
}
public function timeline(): array {
self::$db->prepared_query("
SELECT date(unseeded_date) as `day`,
count(*) as total
FROM torrent_unseeded
GROUP BY `day`
ORDER BY `day` DESC
");
return self::$db->to_pair('day', 'total', false);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Gazelle\Torrent;
enum ReaperNotify: string {
case INITIAL = 'initial';
case FINAL = 'final';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Gazelle\Torrent;
enum ReaperState: string {
case NEVER = 'never';
case UNSEEDED = 'unseeded';
public function notifyAttr(): string {
return match($this) {
ReaperState::NEVER => 'no-pm-unseeded-upload',
ReaperState::UNSEEDED => 'no-pm-unseeded-snatch',
};
}
}

View File

@@ -126,7 +126,6 @@ class User extends BaseObject {
ui.RestrictedForums,
ui.SiteOptions,
ui.SupportFor,
ui.UnseededAlerts,
ui.Warned,
uls.Uploaded,
uls.Downloaded,
@@ -977,6 +976,10 @@ class User extends BaseObject {
DELETE FROM user_flt WHERE user_id = ?
", $this->id
);
self::$db->prepared_query("
DELETE FROM user_has_attr WHERE UserID = ?
", $this->id
);
self::$db->prepared_query("
DELETE FROM users_leech_stats WHERE UserID = ?
", $this->id
@@ -1409,10 +1412,6 @@ class User extends BaseObject {
return $change;
}
public function notifyUnseeded(): bool {
return $this->info()['UnseededAlerts'] == '1';
}
public function notifyDeleteSeeding(): bool {
return $this->info()['NotifyOnDeleteSeeding'] == '1';
}

View File

@@ -46,6 +46,7 @@ services:
- mysqld
- --group-concat-max-len=1048576
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
- --userstat=on
- --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
# neither sphinxsearch or ocelot are compatible with the mysql8 caching_sha2_password plugin

83
docs/09-UnseededPurge.txt Normal file
View File

@@ -0,0 +1,83 @@
From: Spine
To: Operators
Date: 2022-11-20
Subject: Orpheus Development Papers #9 - Unseeded Purges
Version: 1
For various reasons, some uploads will end up in an unseeded state, either
because the uploader forgets to load it into their client so no-one else can
snatch it, or eventually people lose interest and stop seeding, hard disks
fail, and the upload becomes unavailable.
There is a tension to be resolved between a site which appears to have a
large catalogue but full of tombstones, and a site with a smaller catalogue
with pretty much everything is available. Different sites resolve the issue
using different policies. The design described below can handle most use
cases.
The original Gazelle design removed tombstones but allowed little margin for
error. There was an initial alert sent for uploads that had been unseeded
for a specified amount of time, which was run as an hourly scheduled task
that caught all the uploads that had been unseeded for between N and N+1
hours. If the scheduler did not make the run in that hour, no notification
alerts were sent and the upload was removed afterwards with no prior
warning.
This became a massive problem if the reaper has to be stopped for an
extended period of time for whatever reason (hardware crash, server move,
DDoS or other instabilities). When things return to normal, starting up the
reaper would immediately nuke a large number of uploads that might otherwise
have been saved if people received an advance warning to do something about
it.
To get around this problem. the approach to handling unseeded uploads on
Orpheus is to use a table to register where an upload is in its unseeded
state. A row is inserted into the `torrent_unseeded` table when it is deemed
to have been unseeded for too long. From there, the row will either be
removed when the upload is seeded once more, or removed along with the purge
of the upload. Unseeded and "never seeded" uploads are managed using
different schedules.
The Orpheus reaper sends out an initial alert well in advance, to give
people time to even notice the message and do something about it. A second
alert is sent just before the upload is removed.
To leave tombstones in the catalogue, simply disable the scheduled reaper
task.
An unseeded upload is identified by the `torrents_leech_stats.last_action`
column. If it is `NULL`, the upload was never seeded. Once the grace period
since creation (based on `torrents.Time`) has passed, a "never seeded" row
is inserted. If there is a value for `last_action` and the grace period for
unseeded uploads has passed, then an "unseeded" row is inserted.
The current timestamp is recorded when the row is inserted. The subsequent
actions (second alert, ultimate removal) are based on this. If a member
pleads for extra time to get their act together, this can be done by
updating the `unseeded_date` to a time in the future (e.g. `now() + INTERVAL
2 WEEK`. There is no point deleting the row: it will be inserted again
during the next scheduler run.
Snatchers are also pinged to see if they can reseed the upload. The first
person to do so can "claim" the reseed and receive a reward of bonus points.
This is to obviate the risk of a snatcher letting the upload be removed and
then reuploaded under their own name. To prevent abuse, a given upload may
only be claimed once by the same person. The `torrent_unseeded_claim` table
can be reviewed to see if an individual is making excessive use of the
feature.
Unseeded uploads are removed after `REMOVE_UNSEEDED_HOUR` hours (28 days by
default). "Never seeded" uploads are removed after
`REMOVE_NEVER_SEEDED_HOUR` hours (3 days by default). The final alert timer
is most easily defined by calculating an interval prior to the removal
deadline.
To spread the load following a long pause, no more than 100 uploads per
user, and no more than 1000 users are considered in a single run. This helps
produce a breadth-first search, rather than depth first. There is also a
maximum cap of total uploads to process per run.
Remember: the `torrent_unseeded` represents the current state of unseeded
notifications. No real harm will happen if the table is truncated: all this
will do is reinitialize the process. The current stats can be viewed in the
Torrent Stats toolbox.

View File

@@ -459,15 +459,42 @@ defined('IGNORE_PAGE_MAX_TIME') or define('IGNORE_PAGE_MAX_TIME', ['top10']);
// It is easier to specify the first and second unseeded notifications
// in terms of the interval remaining until reaping.
define('MAX_NEVER_SEEDED_PER_RUN', 1000);
define('REMOVE_NEVER_SEEDED_HOUR', 72);
define('NOTIFY_NEVER_SEEDED_INITIAL_HOUR', 8); // 8 hours after upload
define('NOTIFY_NEVER_SEEDED_FINAL_HOUR', REMOVE_NEVER_SEEDED_HOUR - 24);
defined('MAX_NEVER_SEEDED_PER_RUN') or define('MAX_NEVER_SEEDED_PER_RUN', 4000);
defined('REMOVE_NEVER_SEEDED_HOUR') or define('REMOVE_NEVER_SEEDED_HOUR', 72);
defined('NOTIFY_NEVER_SEEDED_INITIAL_HOUR') or define('NOTIFY_NEVER_SEEDED_INITIAL_HOUR', 8); // 8 hours after upload
defined('NOTIFY_NEVER_SEEDED_FINAL_HOUR') or define('NOTIFY_NEVER_SEEDED_FINAL_HOUR', REMOVE_NEVER_SEEDED_HOUR - 24);
define('MAX_UNSEEDED_PER_RUN', 1000);
define('REMOVE_UNSEEDED_HOUR', 24 * 28); // 28 days
define('NOTIFY_UNSEEDED_INITIAL_HOUR', REMOVE_UNSEEDED_HOUR - (24 * 10));
define('NOTIFY_UNSEEDED_FINAL_HOUR', REMOVE_UNSEEDED_HOUR - (24 * 3));
defined('MAX_UNSEEDED_PER_RUN') or define('MAX_UNSEEDED_PER_RUN', 4000);
defined('REMOVE_UNSEEDED_HOUR') or define('REMOVE_UNSEEDED_HOUR', 24 * 30); // 30 days
defined('NOTIFY_UNSEEDED_INITIAL_HOUR') or define('NOTIFY_UNSEEDED_INITIAL_HOUR', REMOVE_UNSEEDED_HOUR - (24 * 10)); // 10 days before
defined('NOTIFY_UNSEEDED_FINAL_HOUR') or define('NOTIFY_UNSEEDED_FINAL_HOUR', REMOVE_UNSEEDED_HOUR - (24 * 3)); // 3 days before
// There is a single task that handles the various phases of reaping.
// In normal operations you want to perform all of the phases, but in certain
// circumstances you might wish to suspend one phase or another.
// If you think you need to suspend all the phases, disable the task in the
// scheduler instead.
// Award winners who have begun to seed unseeded uploads
defined('REAPER_TASK_CLAIM') or define('REAPER_TASK_CLAIM', true);
// Look for new never seeded or unseeded uploads to process
defined('REAPER_TASK_NOTIFY') or define('REAPER_TASK_NOTIFY', true);
// Reap any expired unseeded torrents
defined('REAPER_TASK_REMOVE_UNSEEDED') or define('REAPER_TASK_REMOVE_UNSEEDED', true);
// Reap any expired never seeded torrents
defined('REAPER_TASK_REMOVE_NEVER_SEEDED') or define('REAPER_TASK_REMOVE_NEVER_SEEDED', true);
// How many notifications can a user receive in a single message
defined('NOTIFY_REAPER_MAX_PER_USER') or define('NOTIFY_REAPER_MAX_PER_USER', 200);
// How many users will be notified in a single run?
defined('NOTIFY_REAPER_MAX_NOTIFICATION') or define('NOTIFY_REAPER_MAX_NOTIFICATION', 2500);
// How much is the BP reward scaled up for a reseed?
defined('REAPER_RESEED_REWARD_FACTOR') or define('REAPER_RESEED_REWARD_FACTOR', 1.25);
// ------------------------------------------------------------------------
// Source flag settings

View File

@@ -381,10 +381,14 @@ function add_json_info($Json) {
return $Json;
}
function dump($thing) {
function dump($thing): void {
echo "<pre>" . json_encode($thing, JSON_PRETTY_PRINT) . "</pre>";
}
function show(mixed $data): void {
echo json_encode($data, JSON_PRETTY_PRINT) . "\n";
}
/**
* Utility function that unserializes an array, and then if the unserialization fails,
* it'll then return an empty array instead of a null or false which will break downstream

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class TorrentReaper extends AbstractMigration {
public function up(): void {
$this->table('torrent_unseeded', ['id' => false, 'primary_key' => ['torrent_unseeded_id']])
->addColumn('torrent_unseeded_id', 'integer', ['identity' => true])
->addColumn('torrent_id', 'integer')
->addColumn('state', 'enum', ['default' => 'never', 'values' => ['never', 'unseeded']])
->addColumn('notify', 'enum', ['default' => 'initial', 'values' => ['initial', 'final']])
->addColumn('unseeded_date', 'datetime', ['default' => 'CURRENT_TIMESTAMP'])
->addForeignKey('torrent_id', 'torrents', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addIndex(['torrent_id'], ['name' => 'tu_t_uidx', 'unique' => true])
->addIndex(['unseeded_date'], ['name' => 'tu_ud_idx'])
->create();
$this->table('torrent_unseeded_claim', ['id' => false, 'primary_key' => ['torrent_id', 'user_id']])
->addColumn('torrent_id', 'integer')
->addColumn('user_id', 'integer')
->addColumn('claim_date', 'datetime', ['null' => true])
->addForeignKey('torrent_id', 'torrents', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addForeignKey('user_id', 'users_main', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addIndex(['user_id'], ['name' => 'tuc_u_idx'])
->create();
$this->getQueryBuilder()
->delete('periodic_task')
->where(fn($w) => $w->in('classname', [
'DeleteNeverSeededTorrents',
'DeleteUnseededTorrents',
'NotifyNonseedingUploaders',
])
)
->execute();
$this->table('periodic_task')
->insert([
'name' => 'Torrent Reaper',
'classname' => 'Reaper',
'description' => 'Process torrents that are unseeded/were never seeded',
'period' => 4 * 3600,
'is_enabled' => 0,
])
->save();
$this->table('user_attr')->insert(
[
['Name' => 'no-pm-unseeded-snatch', 'Description' => 'Do not receive system PMs on imminent removal of an unseeded snatch'],
['Name' => 'no-pm-unseeded-upload', 'Description' => 'Do not receive system PMs on imminent removal of an unseeded upload'],
]
)->save();
}
public function down(): void {
$this->table('torrent_unseeded')->drop()->update();
$this->table('torrent_unseeded_claim')->drop()->update();
$this->getQueryBuilder()
->delete('periodic_task')
->where(fn($w) => $w->in('classname', [
'Reaper',
])
)
->execute();
$this->table('periodic_task')
->insert([
[
'name' => 'Torrent Reaper - Never Seeded',
'classname' => 'DeleteNeverSeededTorrents',
'description' => 'Deletes torrents that were never seeded',
'period' => 3600 * 24
],
[
'name' => 'Torrent Reaper - Unseeded',
'classname' => 'DeleteUnseededTorrents',
'description' => 'Deletes unseeded torrents',
'period' => 3600 * 24
],
[
'name' => 'Unseeded Notifications',
'classname' => 'NotifyNonseedingUploaders',
'description' => 'Sends warnings for unseeded torrents',
'period' => 3600 * 24 * 7
],
])
->save();
$this->execute("DELETE FROM user_attr where Name IN ('no-pm-unseeded-snatch', 'no-pm-unseeded-upload')");
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UsersInfoDropUnseededAlerts extends AbstractMigration {
public function up(): void {
$this->table('users_info')->removeColumn('UnseededAlerts')->save();
}
public function down(): void {
$this->table('users_info')->addColumn('UnseededAlerts', 'enum', [
'null' => false,
'default' => '0',
'limit' => 1,
'values' => ['0', '1'],
])->save();
}
}

View File

@@ -3540,11 +3540,6 @@ parameters:
count: 1
path: ../app/Torrent.php
-
message: "#^Method Gazelle\\\\Torrent\\\\Reaper\\:\\:deleteDeadTorrents\\(\\) has no return type specified\\.$#"
count: 1
path: ../app/Torrent/Reaper.php
-
message: "#^Cannot call method addFlag\\(\\) on bool\\|Gazelle\\\\TorrentAbstract\\.$#"
count: 1

View File

@@ -31,6 +31,10 @@ parameters:
- FEATURE_EMAIL_REENABLE
- LASTFM_API_KEY
- OPEN_REGISTRATION
- REAPER_TASK_CLAIM
- REAPER_TASK_NOTIFY
- REAPER_TASK_REMOVE_NEVER_SEEDED
- REAPER_TASK_REMOVE_UNSEEDED
- RECOVERY
- RECOVERY_AUTOVALIDATE
- RECOVERY_DB
@@ -54,6 +58,7 @@ parameters:
message: '/^Property [^:]+::\$\w+ type has no value type specified in iterable type array\.$/'
paths:
- ../app/*
- ../tests/phpunit/*
-
message: '/^Variable \$(?:Cache|DB|Debug|SessionID|Twig|Viewer) might not be defined\.$/'
paths:

View File

@@ -12,7 +12,7 @@
"lint:css": "stylelint \"sass/**/*.scss\" || exit 0",
"lint:css-checkstyle": "stylelint \"sass/**/*.scss\" --custom-formatter ./node_modules/stylelint-checkstyle-formatter/index.js || exit 0",
"lint:php:internal": "find . -path './vendor' -prune -o -path ./node_modules -prune -o -path './.docker' -prune -o -type f -name '*.php' -print0 | xargs -0 -n1 -P4 php -l -n | (! grep -v \"No syntax errors detected\" )",
"lint:php:phpcs": "vendor/bin/phpcs --report-width 1000",
"lint:php:phpcs": "vendor/bin/phpcs -p --report-width 1000",
"lint:php": "yarn lint:php:internal && yarn lint:php:phpcs",
"lint:php:fix": "./.bin/phpcbf",
"pre-commit": "yarn lint:php:fix",

View File

@@ -7,4 +7,5 @@ if (!$Viewer->permitted('site_view_flow')) {
echo $Twig->render('admin/stats/torrent.twig', [
'torr_stat' => new Gazelle\Stats\Torrent,
'user_stat' => new Gazelle\Stats\Users,
'reaper' => new Gazelle\Torrent\Reaper(new Gazelle\Manager\Torrent, new Gazelle\Manager\User),
]);

View File

@@ -2,7 +2,7 @@
$userMan = new Gazelle\Manager\User;
$user = $userMan->findById((int)($_REQUEST['id'] ?? 0));
$user = $userMan->findById(($_REQUEST['id'] ?? '') === 'me' ? $Viewer->id() : (int)($_REQUEST['id'] ?? 0));
if (is_null($user)) {
error(404);
}

View File

@@ -175,7 +175,6 @@ $Options['DisablePMAvatars'] = (!empty($_POST['disablepmavatars']) ? 1 : 0);
$Options['ListUnreadPMsFirst'] = (!empty($_POST['list_unread_pms_first']) ? 1 : 0);
$Options['ShowSnatched'] = (!empty($_POST['showsnatched']) ? 1 : 0);
$Options['DisableAutoSave'] = (!empty($_POST['disableautosave']) ? 1 : 0);
$Options['AcceptFL'] = (!empty($_POST['acceptfltoken']) ? 1 : 0);
$Options['NoVoteLinks'] = (!empty($_POST['novotelinks']) ? 1 : 0);
$Options['CoverArt'] = (int)!empty($_POST['coverart']);
$Options['ShowExtraCovers'] = (int)!empty($_POST['show_extra_covers']);
@@ -206,7 +205,6 @@ if ($Viewer->permitted('site_advanced_search')) {
// These are all enums of '0' or '1'
$DownloadAlt = isset($_POST['downloadalt']) ? '1' : '0';
$UnseededAlerts = isset($_POST['unseededalerts']) ? '1' : '0';
$NotifyOnDeleteSeeding = (!empty($_POST['notifyondeleteseeding']) ? '1' : '0');
$NotifyOnDeleteSnatched = (!empty($_POST['notifyondeletesnatched']) ? '1' : '0');
$NotifyOnDeleteDownloaded = (!empty($_POST['notifyondeletedownloaded']) ? '1' : '0');
@@ -245,8 +243,6 @@ if (is_null($OldFMUsername) && $LastFMUsername !== '') {
$Cache->delete_value("lastfm_username_$userId");
}
$user->toggleAcceptFL($Options['AcceptFL']);
/* transform
* 'notifications_News_popup'
* 'notifications_Blog_popup'
@@ -270,6 +266,9 @@ foreach ($notification as $n) {
}
(new Gazelle\User\Notification($user))->save($settings, ["PushKey" => $_POST['pushkey']], $_POST['pushservice'], $_POST['pushdevice']);
$user->toggleAcceptFL(!empty($_POST['acceptfltoken']));
$user->toggleAttr('no-pm-unseeded-snatch', empty($_POST['notifyonunseededsnatch']));
$user->toggleAttr('no-pm-unseeded-upload', empty($_POST['notifyonunseededupload']));
$user->toggleAttr('hide-vote-recent', empty($_POST['pattr_hide_vote_recent']));
$user->toggleAttr('hide-vote-history', empty($_POST['pattr_hide_vote_history']));
$user->toggleAttr('admin-error-reporting', isset($_POST['error_reporting']));
@@ -286,7 +285,6 @@ INNER JOIN users_info AS i ON (m.ID = i.UserID) SET
i.Info = ?,
i.InfoTitle = ?,
i.DownloadAlt = ?,
i.UnseededAlerts = ?,
i.NotifyOnDeleteSeeding = ?,
i.NotifyOnDeleteSnatched = ?,
i.NotifyOnDeleteDownloaded = ?,
@@ -301,7 +299,6 @@ $Params = [
$_POST['info'],
$_POST['profile_title'],
$DownloadAlt,
$UnseededAlerts,
$NotifyOnDeleteSeeding,
$NotifyOnDeleteSnatched,
$NotifyOnDeleteDownloaded,

View File

@@ -138,6 +138,64 @@
</table>
</div>
</div>
</div>
<div class="thin">
<div class="box">
<div class="head">Reaper information</div>
<div class="pad">
<h5>Claim statistics</h5>
<table>
<tr>
<th width="50%">Open</th>
<th width="50%">Claimed</th>
</tr>
<tr>
{% set stats = reaper.claimStats %}
<td>{{ stats.open|number_format }}</td>
<td>{{ stats.claimed|number_format }}</td>
</tr>
</table>
<br />
<h5>Unseeded statistics</h5>
<table>
<tr>
<th width="25%">Never seeded initial</th>
<th width="25%">Never seeded final</th>
<th width="25%">Unseeded initial</th>
<th width="25%">Unseeded final</th>
</tr>
<tr>
{% set stats = reaper.stats %}
<td>{{ stats.never_seeded_initial|number_format }}</td>
<td>{{ stats.never_seeded_final|number_format }}</td>
<td>{{ stats.unseeded_initial|number_format }}</td>
<td>{{ stats.unseeded_final|number_format }}</td>
</tr>
</table>
<br />
<h5>Timeline</h5>
<table>
<tr>
<th width="50%">Date</th>
<th width="50%">Total</th>
</tr>
{% for date, total in reaper.timeline %}
<tr>
<td>{{ date }}</td>
<td>{{ total|number_format }}</td>
</tr>
{% else %}
<tr>
<td colspan="2">There are no uploads in the reaper pipeline at this time.</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</div>
{{ footer() }}

View File

@@ -0,0 +1,177 @@
<div class="thin">
<div class="box"><div class="head">Overall Stats</div>
<div class="pad">
<table>
<tr>
<td>Total torrents:</td><td class="number_column">{{ stats.torrentCount|number_format }}</td>
<td style="padding-left:30px">Mean torrents per user:</td><td class="number_column">{{
(stats.torrentCount / stats.totalUsers)|number_format }}</td>
<td style="padding-left:30px">Mean files per torrent:</td><td class="number_column">{{
(stats.totalFiles / stats.torrentCount)|number_format }}</td>
</tr>
<tr>
<td>Total size:</td><td style="vertical-align: top" class="number_column">{{ stats.totalSize()|octet_size }}</td>
<td style="padding-left:30px">Mean torrent size:</td><td style="vertical-align: top" class="number_column">{{
(stats.totalSize / stats.torrentCount)|octet_size }}</td>
<td style="padding-left:30px">Mean filesize:</td><td style="vertical-align: top" class="number_column">{{
(stats.totalSize / stats.totalFiles)|octet_size }}</td>
</tr>
<tr>
<td>Total files:</td><td class="number_column">{{ stats.totalFiles|number_format }}</td>
<td colspan="4">&nbsp;</td>
</tr>
</table>
</div>
</div>
<div class="box"><div class="head">Timeline of Unseeded Uploads Removal</div>
<div class="pad">
<table>
<tr>
<td style="width: 25%">Initial unseeded:</td><td style="width: 25%" class="number_column">{{ unseeded.unseeded_initial|number_format }}</td>
<td style="width: 25%; padding-left:30px">Final unseeded:</td><td style="width: 25%" class="number_column">{{ unseeded.unseeded_final|number_format }}</td>
</tr>
<tr>
<td>Initial never seeded:</td><td class="number_column">{{ unseeded.never_seeded_initial|number_format }}</td>
<td style="padding-left:30px">Final never seeded:</td><td class="number_column">{{ unseeded.never_seeded_final|number_format }}</td>
</tr>
{% for day, total in timeline %}
{% if loop.first %}
<td style="width: 25%">Open claims:</td><td style="width: 25%" class="number_column">{{ claim.open|number_format }}</td>
{% else %}
<td style="width: 50%" colspan="2">&nbsp;</td>
{% endif %}
{% if loop.first %}
<td style="width: 50%" colspan="2">Upcoming removals by date</td>
</tr>
<tr>
<td>Validated claims:</td><td class="number_column">{{ claim.claimed|number_format }}</td>
{% endif %}
<td style="width: 25%; padding-left:30px">{{ day }}</td>
<td style="width: 25%; padding-left:30px" class="number_column">{{ total|number_format }}</td>
</tr>
{% endfor %}
{% if timeline|length == 0 %}
<tr>
<td style="width: 25%;">Open claims:</td><td style="width: 25%;" class="number_column">{{ claim.open|number_format }}</td>
<td style="width: 50%; padding-left:30px" colspan="2">Upcoming removals by date</td>
</tr>
<tr>
<td>Validated claims:</td><td class="number_column">{{ claim.claimed|number_format }}</td>
<td style="width: 50%" colspan="2">&nbsp;</td>
</tr>
{% endif %}
</table>
</div>
</div>
<div class="box"><div class="head">Upload Frequency</div>
<div class="pad">
<table>
<tr>
<th></th>
<th>Today</th>
<th>This week</th>
<th>Per day this week</th>
<th>This month</th>
<th>Per day this month</th>
<th>This quarter</th>
<th>Per day this quarter</th>
</tr>
<tr>
<th>Torrents</th>
<td class="number_column">{{ stats.amount('day')|number_format }}</td>
<td class="number_column">{{ stats.amount('week')|number_format }}</td>
<td class="number_column">{{ (stats.amount('week') / 7)|number_format }}</td>
<td class="number_column">{{ stats.amount('month')|number_format }}</td>
<td class="number_column">{{ (stats.amount('month') / 30)|number_format }}</td>
<td class="number_column">{{ stats.amount('quarter')|number_format }}</td>
<td class="number_column">{{ (stats.amount('quarter') / 120)|number_format }}</td>
</tr>
<tr>
<th>Size</th>
<td class="number_column">{{ stats.size('day')|octet_size }}</td>
<td class="number_column">{{ stats.size('week')|octet_size }}</td>
<td class="number_column">{{ (stats.size('week') / 7)|octet_size }}</td>
<td class="number_column">{{ stats.size('month')|octet_size }}</td>
<td class="number_column">{{ (stats.size('month') / 30)|octet_size }}</td>
<td class="number_column">{{ stats.size('quarter')|octet_size }}</td>
<td class="number_column">{{ (stats.size('quarter') / 120)|octet_size }}</td>
</tr>
<tr>
<th>Files</th>
<td class="number_column">{{ stats.files('day')|number_format }}</td>
<td class="number_column">{{ stats.files('week')|number_format }}</td>
<td class="number_column">{{ (stats.files('week') / 7)|number_format }}</td>
<td class="number_column">{{ stats.files('month')|number_format }}</td>
<td class="number_column">{{ (stats.files('month') / 30)|number_format }}</td>
<td class="number_column">{{ stats.files('quarter')|number_format }}</td>
<td class="number_column">{{ (stats.files('quarter') / 120)|number_format }}</td>
</tr>
</table>
</div>
</div>
<div class="box"><div class="head">Content Analysis</div>
<div class="pad">
<table>
<tr>
<th width="33%">Formats</th>
<th width="33%">Media</th>
<th width="34%">Categories</th>
</tr>
<tr>
<td style="vertical-align: top;"><table>
{% for f in stats.format %}
<tr><td>{{ f.0|default('<i>Grand</i>')|raw }}</td>
<td>{{ f.1|default('<i>Total</i>')|raw }}</td>
<td class="number_column">{{ f.2|number_format }}</td>
</tr>
{% endfor %}
</table></td>
<td style="vertical-align: top;"><table>
{% for m in stats.media %}
<tr><td>{{ m.0|default('<i>Total</i>')|raw }}</td>
<td class="number_column">{{ m.1|number_format }}</td>
</tr>
{% endfor %}
</table></td>
<td style="vertical-align: top;"><table>
{% for c in stats.category %}
<tr><td>{{ constant('CATEGORY')[c.0 - 1] }}</td>
<td class="number_column">{{ c.1|number_format }}</td>
</tr>
{% endfor %}
</table></td>
</tr>
<tr>
<th width="33%">Added in last month</th>
<th width="33%">&nbsp;</th>
<th width="34%">&nbsp;</th>
</tr>
<tr>
<td style="vertical-align: top;"><table>
{% for f in stats.formatMonth %}
<tr><td>{{ f.0|default('<i>Grand</i>')|raw }}</td>
<td>{{ f.1|default('<i>Total</i>')|raw }}</td>
<td class="number_column">{{ f.2|number_format }}</td>
</tr>
{% endfor %}
</table></td>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
{%- set total = notes|length -%}
{%- set many = total > 1 -%}
Dear {{ user.username }},
{% if many %}{{ total|number_format }}{% else %}One%{% endif %} of your recent uploads {% if many %}have{% else %}has{%
endif %} been removed because they were never announced to the tracker. Assuming {% if many %}they{% else %}it{%
endif %} didn't break any rules (we hope), please feel free to re-upload {% if many %}them{% else %}it{% endif %}.
The following upload{{ total|plural }} {% if many %}were{% else %}was{% endif %} removed:
{% for n in notes %}
[*] [url=torrents.php?id={{ n.tgroup.id }}]{{ n.name }}[/url]
{% endfor %}
Remember, your uploads earn bonus points on {{ constant('SITE_NAME') }}.

View File

@@ -0,0 +1,16 @@
{%- set total = notes|length -%}
{%- set many = total > 1 -%}
Dear {{ user.username }},
The following {% if many %}uploads were{% else %}upload was{% endif %} removed {#
-#} because nobody was seeding {% if many %}them{% else %}it{% endif %}. If you still {#
-#} have the files (and assuming it didn't break any rules), please feel free to {#
-#} re-upload it. Keep in mind, though, if you had reseeded it, you would have {#
-#} earned more bonus points than if you upload a fresh version.
The following upload{{ total|plural }} {% if many %}were{% else %}was{% endif %} removed:
{% for tgroup in list %}
[*] [url=torrents.php?id={{ tgroup.id }}]{{ tgroup.name }}[/url]
{% endfor %}
Remember, seeding uploads earns bonus points on {{ constant('SITE_NAME') }}.

View File

@@ -0,0 +1,14 @@
{%- set total = notes|length -%}
{%- set many = total > 1 -%}
Dear {{ user.username }},
{% if many %}{{ total|number_format }}{% else %}One%{% endif %} of your uploads {% if many %}have{% else %}has{%
endif %} been removed for inactivity (nobody has been seeding them). Assuming {% if many %}they{% else %}it{%
endif %} didn't break any rules (we hope), please feel free to re-upload {% if many %}them{% else %}it{% endif %}.
The following upload{{ total|plural }} {% if many %}were{% else %}was{% endif %} removed:
{% for n in notes %}
[*] [url=torrents.php?id={{ n.tgroup.id }}]{{ n.name }}[/url]
{% endfor %}
Remember, seeding your uploads earns bonus points on {{ constant('SITE_NAME') }}.

View File

@@ -0,0 +1,7 @@
Dear {{ user.username }},
Thank you for reseeding [pl]{{ torrent.id }}[/pl]. Without your diligence, this upload might have been removed due to inactivity and lost forever!
Your prompt action has ensured it will remain alive on {{ constant('SITE_NAME') }} for other people to discover and enjoy. To thank you for your efforts, you have been awarded {{ points|number_format }} bonus points. And you will continue to earn bonus points as long as you seed this upload.
<3

View File

@@ -0,0 +1,20 @@
{%- set total = list|length -%}
{%- set many = total > 1 -%}
Dear {{ user.username }},
In the past, you snatched {% if many %}{{ total }} uploads{% else %}an upload{%
endif %} that {% if many %}are{% else %}is{% endif %} no longer being seeded, which means {% if many %}they{% else %}it{%
endif %} will be removed in the near future. You can save the situation and prevent this from happening by reseeding {%
if many %}them{% else %}it{% endif %}. If you are the first to do so, you will be rewarded with extra bonus points for your efforts! Remember, seeding anything you snatched earns bonus points on {{
constant('SITE_NAME') }}.
The exact details vary from client to client, but you will need to initiate a check and see that the torrent{% if many %}s are{% else %} is{% endif %} seeding at 100%. If you still have the files but have lost the torrent file, you may obtain a new copy by saving it from the [[n]DL] link. To check the state of an upload, click {% if many %}one of the links{% else %}the link{% endif %} below and look at the "Last active" time. For more information, please read [url=wiki.php?action=article&amp;id=77]this article[/url].
Once you have successfully reseeded the upload, you don't need to do anything else. If you meet all the conditions, you will receive a system PM.
{% for t in list %}
[*] [pl]{{ t }}[/pl]
{% endfor -%}
If you no longer wish to receive these notifications, you may turn them off in [url=/user.php?action=edit&amp;userid=me#notification]your profile settings[/url]. At any time, you may view your [url=/torrents.php?userid=me&amp;type=uploaded-unseeded]unseeded uploads[/url] and your [url=/torrents.php?userid=me&amp;type=snatched-unseeded]unseeded snatches[/url].
<3

View File

@@ -0,0 +1,33 @@
{%- set total = list|length -%}
{%- set many = total > 1 -%}
Dear {{ user.username }},
You have {% if many %}{{ total }} uploads{% else %}an upload{% endif %} that
{%- if never -%}
{%- if many %} were{% else %} was{% endif %} uploaded {% if final %}a couple of days ago{% else %}several hours ago{% endif %}, but {%
if many %}have{% else %}has{% endif %} yet to be announced to the tracker.
{%- else -%}
{%- if many %} are{% else %} is{% endif %} {% if final %}still {% endif %}not currently seeding by you (or anyone else).
{%- endif %} You are encouraged to check your client to see that it is working correctly.
{%- if final %}
If {% if many %}these uploads are{% else %}this upload is{% endif %} not seeded within the next {%
if never %}day{% else %}few days{% endif %}, {% if many %}they{% else %}it{% endif %} will be removed.
{%- else %}
We would be grateful if you could get {%
if many %}these uploads{% else %}this upload{% endif %} seeding within the next {%
if never %}two days{% else %}week{% endif %} to avoid {% if many %}their{% else %}its{% endif %} removal.
{%- endif %} Remember, seeding uploads earn bonus points on {{ constant('SITE_NAME') }}.
The exact details vary from client to client, but you will need to initiate a check and see that the torrent{%
if many %}s are{% else %} is{% endif %} seeding at 100%. If you still have the files but have lost the torrent file, {#
#}you may obtain a new copy by saving it from the [[n]DL] link. To check the state of an upload, click {%
if many %}one of the links{% else %}the link{% endif %} below and look at the "Last active" time. {#
#}For more information, please read [url=wiki.php?action=article&amp;id=77]this article[/url].
{% for t in list %}
[*] [pl]{{ t }}[/pl]
{% endfor -%}
If you no longer wish to receive these notifications, you may turn them off in [url=/user.php?action=edit&amp;userid=me#notification]your profile settings[/url]. At any time, you may view your [url=/torrents.php?userid=me&amp;type=uploaded-unseeded]unseeded uploads[/url] and your [url=/torrents.php?userid=me&amp;type=snatched-unseeded]unseeded snatches[/url].
<3

View File

@@ -1,8 +1,9 @@
{% from 'macro/form.twig' import checked %}
<div id="access">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="access_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Access Settings</strong>
<a href="#access"><strong>Access Settings</strong></a>
</td>
</tr>
@@ -128,3 +129,4 @@
{% endif %}
</table>
</div>

View File

@@ -1,8 +1,9 @@
{% from 'macro/form.twig' import checked, selected %}
<div id="site-appearance">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="site_appearance_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Site Appearance Settings</strong>
<a href="#site-appearance"><strong>Site Appearance Settings</strong></a>
</td>
</tr>
<tr id="site_style_tr">
@@ -63,3 +64,4 @@
</td>
</tr>
</table>
</div>

View File

@@ -1,8 +1,9 @@
{% from 'macro/form.twig' import checked, selected %}
<div id="community">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="community_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Community Settings</strong>
<a href="#community"><strong>Community Settings</strong></a>
</td>
</tr>
<tr id="comm_ppp_tr">
@@ -95,3 +96,4 @@
</td>
</tr>
</table>
</div>

View File

@@ -1,8 +1,9 @@
{% from 'macro/form.twig' import checked, selected %}
<div id="torrent">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="torrent_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Torrent Settings</strong>
<a href="#torrent"><strong>Torrent Settings</strong></a>
</td>
</tr>
@@ -163,6 +164,7 @@
</td>
</tr>
</table>
</div>
<script type="text/javascript" id="sortable_default">
//<![CDATA[
var sortable_list_default = '{{ release }}';

View File

@@ -1,8 +1,9 @@
{% from 'macro/form.twig' import checked %}
<div id="navigation">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="navigation_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Navigation Settings</strong>
<a href="#navigation"><strong>Navigation Settings</strong></a>
</td>
</tr>
<tr>
@@ -19,3 +20,4 @@
</td>
</tr>
</table>
</div>

View File

@@ -24,11 +24,11 @@
]) }} /> Traditional
</label>
{% endmacro %}
<div id="notification">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="notification_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Notification Settings</strong>
<a href="#notification"><strong>Notification Settings</strong></a>
</td>
</tr>
<tr id="notif_autosubscribe_tr">
@@ -40,8 +40,26 @@
<label for="autosubscribe">Enable automatic thread subscriptions</label>
</td>
</tr>
<tr id="notif_notifyunseededsnatch_tr">
<td class="label tooltip" title="Enabling this will send you a PM alert whenever a torrent you have snatched is no longer being seeded. You can reseed it to earn BP.">
<strong>Unseeded snatches alerts</strong>
</td>
<td>
<input type="checkbox" name="notifyonunseededsnatch" id="notifyonunseededsnatch"{{ checked(not user.hasAttr('no-pm-unseeded-snatch')) }} />
<label for="notifyonunseededsnatch">Receive PM alerts when a snatched upload is not being seeded and faces imminent removal</label>
</td>
</tr>
<tr id="notif_notifyunseededupload_tr">
<td class="label tooltip" title="Enabling this will send you a PM alert whenever a torrent you have uploaded is no longer being seeded. You can reseed it to earn BP.">
<strong>Unseeded uploads alerts</strong>
</td>
<td>
<input type="checkbox" name="notifyonunseededupload" id="notifyonunseededupload"{{ checked(not user.hasAttr('no-pm-unseeded-upload')) }} />
<label for="notifyonunseededupload">Receive PM alerts when one of your uploads is not being seeded and faces imminent removal</label>
</td>
</tr>
<tr id="notif_notifyondeleteseeding_tr">
<td class="label tooltip" title="Enabling this will send you a PM alert whenever a torrent you're seeding is deleted.">
<td class="label tooltip" title="Enabling this will send you a PM alert whenever a torrent you are seeding is deleted.">
<strong>Deleted seeding torrent alerts</strong>
</td>
<td>
@@ -67,15 +85,6 @@
<label for="notifyondeletedownloaded">Enable PM notification for deleted downloaded torrents</label>
</td>
</tr>
<tr id="notif_unseeded_tr">
<td class="label tooltip" title="Enabling this will send you a PM alert before your uploads are deleted for being unseeded.">
<strong>Unseeded torrent alerts</strong>
</td>
<td>
<input type="checkbox" name="unseededalerts" id="unseededalerts"{{ checked(user.notifyUnseeded) }} />
<label for="unseededalerts">Enable unseeded torrent alerts</label>
</td>
</tr>
<tr>
<td class="label"><strong>Push notifications</strong></td>
@@ -176,3 +185,4 @@
</td>
</tr>
</table>
</div>

View File

@@ -1,9 +1,10 @@
{% from 'macro/form.twig' import checked %}
{% from 'macro/user-edit.twig' import paranoia_list %}
<div id="paranoia">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="paranoia_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Paranoia Settings</strong>
<a href="#paranoia"><strong>Paranoia Settings</strong></a>
</td>
</tr>
<tr>
@@ -150,3 +151,4 @@
<td><a href="#" id="preview_paranoia" class="brackets">Preview paranoia</a></td>
</tr>
</table>
</div>

View File

@@ -1,7 +1,8 @@
<div id="personal">
<table cellpadding="6" cellspacing="1" border="0" width="100%" class="layout border user_options" id="personal_settings">
<tr class="colhead_dark">
<td colspan="2">
<strong>Personal Settings</strong>
<a href="#personal"><strong>Personal Settings</strong></a>
</td>
</tr>
<tr id="pers_avatar_tr">
@@ -109,3 +110,4 @@
</td>
</tr>
</table>
</div>

View File

@@ -0,0 +1,601 @@
<?php
use \PHPUnit\Framework\TestCase;
/**
* Note to Developers/Testers
* ==========================
*
* In the context of a local development environment, this unit
* test is fairly sensitive to parameters outside its control.
* Tests may fail because of _other_ unseeded/never seeded uploads
* that you have created locally.
*
* If you receive errors (notably never-initial-0-count or
* unseeded-initial-0-count) the tests are likely catching your
* uploads in its net. To work around this:
*
* UPDATE torrents_leech_stats SET last_action = now()
*
* will make the tests happy and everything should succeed. Any
* other problems are your own.
*/
require_once(__DIR__ . '/../../lib/bootstrap.php');
class ReaperTest extends TestCase {
protected string $tgroupName;
protected array $torrentList = [];
protected array $userList = [];
public function setUp(): void {
// we need two users, one who uploads and one who snatches
$_SERVER['HTTP_USER_AGENT'] = 'phpunit';
$creator = new Gazelle\UserCreator;
$this->userList = [
$creator->setUsername('reaper.' . randomString(10))
->setEmail(randomString(10) . '@reaper.example.com')
->setPassword(randomString())
->setIpaddr('127.0.0.1')
->setAdminComment('created by phpunit ReaperTest')
->create(),
$creator->setUsername('reaper.' . randomString(10))
->setUsername('reaper-' . randomString(10))
->setEmail(randomString(10) . '@reaper.example.com')
->setPassword(randomString())
->setIpaddr('127.0.0.1')
->setAdminComment('created by phpunit ReaperTest')
->create(),
];
// enable them and wipe their inboxes (there is only one message)
foreach ($this->userList as $user) {
$user->setUpdate('Enabled', '1')->modify();
foreach ((new Gazelle\Inbox($user))->messageList(1, 0) as $pm) {
$pm->remove();
}
}
// create a torrent group
$this->tgroupName = 'phpunit reaper ' . randomString(6);
$tgroup = (new Gazelle\Manager\TGroup)->create(
categoryId: 1,
releaseType: 1,
name: $this->tgroupName,
description: 'phpunit reaper description',
image: '',
year: 2022,
recordLabel: 'Unitest Artists Corporation',
catalogueNumber: 'UA-808',
showcase: false,
);
$tgroup->addArtists($this->userList[0], [ARTIST_MAIN], ['Reaper Girl ' . randomString(12)]);
$tagMan = new Gazelle\Manager\Tag;
$tagId = $tagMan->create('electronic', $this->userList[0]->id());
$tagMan->createTorrentTag($tagId, $tgroup->id(), $this->userList[0]->id(), 10);
$tgroup->refresh();
// and add some torrents to the group
$this->torrentList = array_map(fn($info) => (new \Gazelle\Manager\Torrent)->create(
tgroupId: $tgroup->id(),
userId: $this->userList[0]->id(),
description: 'reaper release description',
media: 'WEB',
format: 'FLAC',
encoding: 'Lossless',
infohash: 'infohash-' . randomString(10),
filePath: 'unit-test',
fileList: [],
size: $info['size'],
isScene: false,
isRemaster: true,
remasterYear: 2023,
remasterTitle: $info['title'],
remasterRecordLabel: 'Unitest Artists',
remasterCatalogueNumber: 'UA-REAP-1',
), [
[
'title' => 'Deluxe Edition',
'size' => 20_000_000,
], [
'title' => 'Limited Edition',
'size' => 15_000_000,
]
]
);
}
public function tearDown(): void {
$this->removeUnseededAlert($this->torrentList);
$tgroup = $this->torrentList[0]->group();
$torMan = new Gazelle\Manager\Torrent;
foreach ($this->torrentList as $torrent) {
$torrent = $torMan->findById($torrent->id());
if (is_null($torrent)) {
continue;
}
[$ok, $message] = $torrent->remove($this->userList[0]->id(), 'reaper unit test');
if (!$ok) {
print "error $message [{$this->userList[0]->id()}]\n";
}
}
$tgroup->remove($this->userList[0]);
foreach ($this->userList as $user) {
$user->remove();
}
}
// --- HELPER FUNCTIONS ----
protected function generateReseed(Gazelle\Torrent $torrent, Gazelle\User $user): void {
$db = Gazelle\DB::DB();
$db->prepared_query("
UPDATE torrents_leech_stats SET last_action = now() WHERE TorrentID = ?
", $torrent->id()
);
$db->prepared_query("
INSERT INTO xbt_files_users
(fid, uid, useragent, peer_id, active, remaining, ip, timespent, mtime)
VALUES (?, ?, ?, ?, 1, 0, '127.0.0.1', 1, unix_timestamp(now() - interval 1 hour))
", $torrent->id(), $user->id(), 'ua-' . randomString(12), randomString(20)
);
}
protected function generateSnatch(Gazelle\Torrent $torrent, Gazelle\User $user): void {
Gazelle\DB::DB()->prepared_query("
INSERT INTO xbt_snatched
(fid, uid, tstamp, IP, seedtime)
VALUES (?, ?, unix_timestamp(now()), '127.0.0.1', 1)
", $torrent->id(), $user->id()
);
}
protected function modifyLastAction(Gazelle\Torrent $torrent, int $interval): void {
Gazelle\DB::DB()->prepared_query("
UPDATE torrents_leech_stats SET
last_action = now() - INTERVAL ? HOUR
WHERE TorrentID = ?
", $interval, $torrent->id()
);
}
protected function modifyUnseededInterval(Gazelle\Torrent $torrent, int $hour): void {
Gazelle\DB::DB()->prepared_query("
UPDATE torrent_unseeded SET
unseeded_date = ?
WHERE torrent_id = ?
", Date('Y-m-d H:i:s', (int)strtotime("-{$hour} hours")), $torrent->id()
);
}
protected function removeUnseededAlert(array $list): void {
Gazelle\DB::DB()->prepared_query("
DELETE FROM torrent_unseeded WHERE torrent_id in (" . placeholders($list) . ")"
, ...array_map(fn($t) => $t->id(), $list)
);
}
/**
* This method is not necessary per se, but came in handy when debugging the tests
*/
protected function status(array $torrentList): void {
$db = Gazelle\DB::DB();
$db->prepared_query("
SELECT t.ID,
t.Time,
tu.unseeded_date < tls.last_action,
coalesce(tls.last_action, 'null'),
coalesce(tu.unseeded_date, 'null'),
coalesce(tu.notify, 'null'),
coalesce(tu.state, 'null')
FROM torrents t
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID)
LEFT JOIN torrent_unseeded tu ON (tu.torrent_id = t.ID)
WHERE t.ID IN (" . placeholders($torrentList) . ")"
, ...array_map(fn($t) => $t->id(), $torrentList)
);
echo implode("\t", ['id', 'created', 'created<last?', 'last_action', 'unseeded', 'final', 'never_seeded']), "\n";
echo implode("\n",
array_map(fn($r) => implode("\t", $r), $db->to_array(false, MYSQLI_NUM, false))
) . "\n";
}
// -------------------------
public function testExpand(): void {
$reaper = new \Gazelle\Torrent\Reaper(new Gazelle\Manager\Torrent, new Gazelle\Manager\User);
$this->assertEquals(
[456 => [99, 88, 77]],
$reaper->expand(100, [[456, '99,88,77']]),
'torrent-reaper-expand-1',
);
$this->assertEquals(
[
456 => [99, 88, 77],
567 => [10, 20, 30, 40, 50],
678 => [321],
],
$reaper->expand(100, [
[456, '99,88,77'],
[567, '10,20,30,40,50'],
[678, '321'],
]), 'torrent-reaper-expand-2',
);
$this->assertEquals(
[
456 => [99, 88],
567 => [10, 20],
678 => [321],
],
$reaper->expand(2, [
[456, '99,88,77'],
[567, '10,20,30,40,50'],
[678, '321'],
]),
'torrent-reaper-expand-limit',
);
}
public function testNeverSeeded(): void {
$user = $this->torrentList[0]->uploader();
$inbox = new Gazelle\Inbox($user);
$this->assertEquals(0, $inbox->messageTotal(), 'never-inbox-initial');
$torMan = new Gazelle\Manager\Torrent;
$reaper = new \Gazelle\Torrent\Reaper($torMan, new Gazelle\Manager\User);
$initialUnseededStats = $reaper->stats();
// reset the time of the never seeded alert back in time to hit the initial timeout
$hour = NOTIFY_NEVER_SEEDED_INITIAL_HOUR + 1;
$neverSeededInitialDate = Date('Y-m-d H:i:s', strtotime("-{$hour} hours"));
$this->torrentList[0]->setUpdate('Time', $neverSeededInitialDate)->modify();
// look for never seeded
$neverInitial = $reaper->initialNeverSeededList();
$this->assertCount(1, $neverInitial, 'never-initial-0-count');
$this->assertEquals(
1,
$reaper->process(
$neverInitial,
Gazelle\Torrent\ReaperState::NEVER,
Gazelle\Torrent\ReaperNotify::INITIAL
),
'never-initial-process'
);
$this->assertEquals(
[
"never_seeded_initial" => $initialUnseededStats['never_seeded_initial'] + 1,
"never_seeded_final" => 0,
"unseeded_initial" => 0,
"unseeded_final" => 0,
],
$reaper->stats(),
'reaper-unseeded-stats-0'
);
// check the notification
$this->assertEquals(1, $inbox->messageTotal(), 'never-message-initial-count');
$pm = $inbox->messageList(1, 0)[0];
$this->assertEquals('You have a non-seeded new upload to rescue', $pm->subject(), 'never-message-initial-subject');
$pm->remove();
// reset the unseeded entries and time out a second upload
$this->removeUnseededAlert($this->torrentList);
$this->torrentList[1]->setUpdate('Time', $neverSeededInitialDate)->modify();
$neverInitial = $reaper->initialNeverSeededList();
$this->assertCount(1, $neverInitial, 'never-initial-2'); // one user ...
$this->assertEquals(2, // ... with two uploads
$reaper->process(
$neverInitial,
Gazelle\Torrent\ReaperState::NEVER,
Gazelle\Torrent\ReaperNotify::INITIAL
),
'never-2-process'
);
$this->assertEquals(
[
"never_seeded_initial" => $initialUnseededStats['never_seeded_initinal'] + 2,
"never_seeded_final" => 0,
"unseeded_initial" => 0,
"unseeded_final" => 0,
],
$reaper->stats(),
'reaper-unseeded-stats-1'
);
$this->assertEquals(1, $inbox->messageTotal(), 'never-message-2');
$pm = $inbox->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('You have 2 non-seeded new uploads to rescue', $pm->subject(), 'never-message-2');
$this->assertStringContainsString("Dear {$this->userList[0]->username()}", $body, 'never-body-2-dear');
$this->assertStringContainsString("[pl]{$this->torrentList[0]->id()}[/pl]", $body, 'never-body-2-pl-0');
$this->assertStringContainsString("[pl]{$this->torrentList[1]->id()}[/pl]", $body, 'never-body-2-pl-1');
$pm->remove();
// reseed one of the torrents by the uploader
$this->generateReseed($this->torrentList[0], $this->torrentList[0]->uploader());
$this->torrentList[1]->setUpdate('Time', Date('Y-m-d H:i:s'))->modify();
// reset the time of the remaing never seeded alert back in time to hit
// the final timeout.
// reminder: once a torrent is referenced in the torrent_unseeded table,
// we no longer need to consider the torrent creation date.
$this->modifyUnseededInterval($this->torrentList[1], NOTIFY_NEVER_SEEDED_FINAL_HOUR + 1);
$this->assertEquals(0, $inbox->messageTotal(), 'never-message-final-none');
$neverFinal = $reaper->finalNeverSeededList();
$this->assertCount(1, $neverFinal, 'never-final-0'); // one user ...
$this->assertEquals(1, // ... with one upload
$reaper->process(
$neverFinal,
Gazelle\Torrent\ReaperState::NEVER,
Gazelle\Torrent\ReaperNotify::FINAL
),
'never-final-process'
);
$this->assertEquals(
[
"never_seeded_initial" => $initialUnseededStats['never_seeded_initial'] + 1,
"never_seeded_final" => $initialUnseededStats['never_seeded_final'] + 1,
"unseeded_initial" => 0,
"unseeded_final" => 0,
],
$reaper->stats(),
'reaper-unseeded-stats-final'
);
$this->assertEquals(1, $inbox->messageTotal(), 'never-message-final-count');
$pm = $inbox->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('You have a non-seeded new upload scheduled for deletion very soon', $pm->subject(), 'never-message-final-subject');
$this->assertStringContainsString("Dear {$this->userList[0]->username()}", $body, 'never-body-3-dear');
$this->assertStringContainsString("[pl]{$this->torrentList[1]->id()}[/pl]", $body, 'never-body-3-pl-1');
$pm->remove();
$this->modifyUnseededInterval($this->torrentList[1], REMOVE_NEVER_SEEDED_HOUR + 1);
$id = $this->torrentList[1]->id();
$list = $reaper->reaperList(
state: Gazelle\Torrent\ReaperState::NEVER,
interval: REMOVE_NEVER_SEEDED_HOUR,
);
$this->assertCount(1, $list, 'never-reap-list');
$this->assertEquals(1, $reaper->removeNeverSeeded(), 'never-reap-remove');
$pm = $inbox->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('1 of your uploads has been deleted for inactivity (never seeded)', $pm->subject(), 'never-remove-message');
$this->assertStringContainsString("Dear {$this->userList[0]->username()}", $body, 'never-remove-body-dear');
$this->assertStringContainsString("[url=torrents.php?id={$this->torrentList[1]->group()->id()}]", $body, 'never-remove-body-pl');
$pm->remove();
$deleted = $torMan->findById($id);
$this->assertNull($deleted, 'never-was-reaped');
}
public function testUnseeded(): void {
$torMan = new Gazelle\Manager\Torrent;
$reaper = new \Gazelle\Torrent\Reaper($torMan, new Gazelle\Manager\User);
$initialUnseededStats = $reaper->stats();
$unseededInitial = $reaper->initialUnseededList();
$this->assertCount(0, $unseededInitial, 'nseeded-initial-0-count');
// reset the last action and time of the unseeded alert back in time to hit the initial timeout
foreach ($this->torrentList as $torrent) {
$hour = NOTIFY_UNSEEDED_INITIAL_HOUR + 1;
$torrent->setUpdate('Time', Date('Y-m-d H:i:s', strtotime("-{$hour} hours")))->modify();
$this->modifyLastAction($torrent, NOTIFY_UNSEEDED_INITIAL_HOUR + 2);
// pretend they were snatched
foreach ($this->userList as $user) {
$this->generateSnatch($torrent, $user);
}
}
// look for unseeded
$unseededInitial = $reaper->initialUnseededList();
$this->assertCount(1, $unseededInitial, 'unseeded-initial-1');
$this->assertEquals(
2,
$reaper->process(
$unseededInitial,
Gazelle\Torrent\ReaperState::UNSEEDED,
Gazelle\Torrent\ReaperNotify::INITIAL
),
'unseeded-initial-process'
);
$this->assertEquals(
[
"never_seeded_initial" => 0,
"never_seeded_final" => 0,
"unseeded_initial" => $initialUnseededStats['unseeded_seeded_initial'] + 2,
"unseeded_final" => 0,
],
$reaper->stats(),
'reaper-unseeded-stats-0'
);
$inboxList = [
new Gazelle\Inbox($this->userList[0]),
new Gazelle\Inbox($this->userList[1]),
];
$this->assertEquals(1, $inboxList[0]->messageTotal(), 'unseeded-initial-0-inbox');
$pm = $inboxList[0]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('There are 2 unseeded uploads to rescue', $pm->subject(), 'unseeded-initial-0-rescue');
$this->assertStringContainsString("You have 2 uploads that are not currently seeding by you (or anyone else)", $body, 'unseeded-initial-0-body');
$this->assertStringContainsString("[pl]{$this->torrentList[0]->id()}[/pl]", $body, 'unseeded-initial-0-body-pl-0');
$this->assertStringContainsString("[pl]{$this->torrentList[1]->id()}[/pl]", $body, 'unseeded-initial-0-body-pl-1');
$pm->remove();
$this->assertEquals(1, $inboxList[1]->messageTotal(), 'unseeded-initial-1-inbox');
$pm = $inboxList[1]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('You have 2 unseeded snatches to save', $pm->subject(), 'unseeded-initial-1-rescue');
$this->assertStringContainsString("In the past, you snatched 2 uploads that are no longer being seeded", $body, 'unseeded-initial-1-body');
$this->assertStringContainsString("[pl]{$this->torrentList[0]->id()}[/pl]", $body, 'unseeded-initial-1-body-pl-0');
$this->assertStringContainsString("[pl]{$this->torrentList[1]->id()}[/pl]", $body, 'unseeded-initial-1-body-pl-1');
$pm->remove();
$initialClaim = $reaper->claimStats();
$this->assertEquals(['open', 'claimed'], array_keys($initialClaim), 'claim-initial');
$this->assertEquals(2, $initialClaim['open'], 'claim-open');
$this->assertEquals(0, $initialClaim['claimed'], 'claim-claimed');
// snatcher reseeds the first upload
$this->modifyUnseededInterval($this->torrentList[0], NOTIFY_UNSEEDED_INITIAL_HOUR + 3);
$this->generateReseed($this->torrentList[0], $this->userList[1]);
// and wins the glory
$bonus = $this->userList[1]->bonusPointsTotal();
$win = $reaper->claim();
$this->assertCount(1, $win, 'unseeded-claim-win-count');
[$torrentId, $userId, $bp] = $win[0];
$this->assertEquals($this->torrentList[0]->id(), $torrentId, 'unseeded-claim-win-torrent');
$this->assertEquals($this->userList[1]->id(), $userId, 'unseeded-claim-win-user');
$this->assertEquals($this->userList[1]->flush()->bonusPointsTotal(), $bonus + $bp, 'unseeded-claim-win-bp');
// message
$pm = $inboxList[1]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals("Thank you for reseeding {$this->torrentList[0]->group()->name()}!", $pm->subject(), 'unseeded-initial-1-thx');
$this->assertStringContainsString("[pl]{$this->torrentList[0]->id()}[/pl]", $body, 'unseeded-initial-1-body-pl-thx');
$this->assertStringContainsString("$bp bonus points", $body, 'unseeded-initial-1-body-bp-thx');
$pm->remove();
$newClaim = $reaper->claimStats();
$this->assertEquals(1, $newClaim['open'], 'claim-new-open');
$this->assertEquals(1, $newClaim['claimed'], 'claim-new-claimed');
// reset the time of the remaing unseeded alert back in time to hit the final timeout.
$this->modifyLastAction($this->torrentList[1], NOTIFY_UNSEEDED_FINAL_HOUR + 2);
$this->modifyUnseededInterval($this->torrentList[1], NOTIFY_UNSEEDED_FINAL_HOUR + 1);
$unseededStats = $reaper->stats();
$unseededFinal = $reaper->finalUnseededList();
$this->assertCount(1, $unseededFinal, 'unseeded-final-0');
$this->assertEquals(1,
$reaper->process(
$unseededFinal,
Gazelle\Torrent\ReaperState::UNSEEDED,
Gazelle\Torrent\ReaperNotify::FINAL
),
'unseeded-final-process'
);
$this->assertEquals(
[
"never_seeded_initial" => 0,
"never_seeded_final" => 0,
"unseeded_initial" => 0,
"unseeded_final" => $unseededStats['unseeded_final'] + 1,
],
$reaper->stats(),
'reaper-unseeded-stats-final'
);
$this->assertEquals(1, $inboxList[0]->messageTotal(), 'unseeded-final-0-inbox');
$pm = $inboxList[0]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('There is an unseeded upload scheduled for deletion very soon', $pm->subject(), 'unseeded-final-0-rescue');
$this->assertStringContainsString("You have an upload that is still not currently seeding by you (or anyone else)", $body, 'unseeded-final-0-body');
$this->assertStringContainsString("[pl]{$this->torrentList[1]->id()}[/pl]", $body, 'unseeded-final-0-body-pl-1');
$pm->remove();
$this->assertEquals(0, $inboxList[1]->messageTotal(), 'unseeded-final-no-snatcher-inbox');
// too late
$this->modifyUnseededInterval($this->torrentList[1], REMOVE_UNSEEDED_HOUR + 1);
$id = $this->torrentList[1]->id();
$list = $reaper->reaperList(
state: Gazelle\Torrent\ReaperState::UNSEEDED,
interval: REMOVE_UNSEEDED_HOUR,
);
$this->assertCount(1, $list, 'unseeded-reap-list');
$this->assertEquals(1, $reaper->removeUnseeded(), 'unseeded-reap-remove');
$pm = $inboxList[0]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('1 of your uploads has been deleted for inactivity (unseeded)', $pm->subject(), 'never-remove-message');
$this->assertStringContainsString("Dear {$this->userList[0]->username()}", $body, 'never-remove-body-dear');
$this->assertStringContainsString("[url=torrents.php?id={$this->torrentList[1]->group()->id()}]", $body, 'never-remove-body-pl');
$pm->remove();
$pm = $inboxList[1]->messageList(1, 0)[0];
$body = $pm->postList(1, 0)[0]['body'];
$this->assertEquals('1 of your snatches was deleted for inactivity', $pm->subject(), 'never-remove-snatcher-message');
$this->assertStringContainsString("Dear {$this->userList[1]->username()}", $body, 'never-remove-snatcher-body-dear');
$this->assertStringContainsString("[url=torrents.php?id={$this->torrentList[1]->group()->id()}]", $body, 'never-remove-snatcher-body-pl');
$pm->remove();
$deleted = $torMan->findById($id);
$this->assertNull($deleted, 'unseeded-was-reaped');
}
public function testNeverNotify(): void {
// like never, but checking that people who have asked not to receive notifications, do not.
$this->userList[0]->toggleAttr('no-pm-unseeded-upload', true);
$this->userList[1]->toggleAttr('no-pm-unseeded-snatch', true);
$this->assertTrue($this->userList[0]->hasAttr('no-pm-unseeded-upload'), 'reaper-no-notify-upload');
$this->assertTrue($this->userList[1]->hasAttr('no-pm-unseeded-snatch'), 'reaper-no-notify-snatch');
// reset the last action and time of the unseeded alert back in time to hit the initial timeout
foreach ($this->torrentList as $torrent) {
$hour = NOTIFY_NEVER_SEEDED_INITIAL_HOUR + 1;
$torrent->setUpdate('Time', Date('Y-m-d H:i:s', strtotime("-{$hour} hours")))->modify();
$this->modifyLastAction($torrent, NOTIFY_NEVER_SEEDED_INITIAL_HOUR + 2);
}
// look for never seeded
$reaper = new \Gazelle\Torrent\Reaper(new Gazelle\Manager\Torrent, new Gazelle\Manager\User);
$reaper->process(
$reaper->initialNeverSeededList(),
Gazelle\Torrent\ReaperState::NEVER,
Gazelle\Torrent\ReaperNotify::INITIAL
);
$this->assertEquals(0, (new Gazelle\Inbox($this->userList[0]))->messageTotal(), 'never-uploader-no-notify');
$this->assertEquals(0, (new Gazelle\Inbox($this->userList[1]))->messageTotal(), 'never-snatcher-no-notify');
}
public function testUnseededNotify(): void {
// like unseeded, but checking that people who have asked not to receive notifications, do not.
$this->userList[0]->toggleAttr('no-pm-unseeded-upload', true);
$this->userList[1]->toggleAttr('no-pm-unseeded-snatch', true);
$this->assertTrue($this->userList[0]->hasAttr('no-pm-unseeded-upload'), 'reaper-no-notify-upload');
$this->assertTrue($this->userList[1]->hasAttr('no-pm-unseeded-snatch'), 'reaper-no-notify-snatch');
// reset the last action and time of the unseeded alert back in time to hit the initial timeout
foreach ($this->torrentList as $torrent) {
$hour = NOTIFY_UNSEEDED_INITIAL_HOUR + 1;
$torrent->setUpdate('Time', Date('Y-m-d H:i:s', strtotime("-{$hour} hours")))->modify();
$this->modifyLastAction($torrent, NOTIFY_UNSEEDED_INITIAL_HOUR + 2);
$this->generateSnatch($torrent, $this->userList[1]);
}
// look for unseeded
$reaper = new \Gazelle\Torrent\Reaper(new Gazelle\Manager\Torrent, new Gazelle\Manager\User);
$reaper->process(
$reaper->initialUnseededList(),
Gazelle\Torrent\ReaperState::UNSEEDED,
Gazelle\Torrent\ReaperNotify::INITIAL
);
$this->assertEquals(1, (new Gazelle\Inbox($this->userList[0]))->messageTotal(), 'unseeded-uploader-no-notify');
$this->assertEquals(0, (new Gazelle\Inbox($this->userList[1]))->messageTotal(), 'unseeded-snatcher-no-notify');
$timeline = $reaper->timeline();
$this->assertEquals([2], array_values($timeline), 'reaper-two-today');
$today = current(array_keys($timeline));
// take into account the chance of running a few seconds before midnight
$this->assertContains($today, [Date('Y-m-d'), Date('Y-m-d', strtotime('1 hour ago'))], 'reaper-timeline');
}
}