tgroupId()) { self::$cache->delete_value("requests_group_" . $this->tgroupId()); } self::$cache->delete_multi([ sprintf(self::CACHE_REQUEST, $this->id), sprintf(self::CACHE_ARTIST, $this->id), sprintf(self::CACHE_VOTE, $this->id), ]); unset($this->info); return $this; } public function link(): string { return sprintf('%s', $this->url(), display_str($this->title())); } public function location(): string { return 'requests.php?action=view&id=' . $this->id; } /** * Display a title on the request page itself. If there are artists in the name, * they will be linkified, and the request title itself will not */ public function selfLink(): string { $title = display_str($this->title()); return match ($this->categoryName()) { 'Music' => "{$this->artistRole()->link()} – " . ($this->isFilled() ? "torrentId()}\" dir=\"ltr\">$title" : $title ) . " [{$this->year()}]", 'Audiobooks', 'Comedy' => $this->isFilled() ? "torrentId()}\" dir=\"ltr\">$title [{$this->year()}]" : "$title [{$this->year()}]", default => $this->isFilled() ? "torrentId()}\" dir=\"ltr\">$title" : $title, }; } /** * Display the title of a request, with all fields linkified where it makes sense. */ public function smartLink(): string { return match ($this->categoryName()) { 'Music' => "{$this->artistRole()->link()} – {$this->link()} [{$this->year()}]", 'Audiobooks', 'Comedy' => "{$this->link()} [{$this->year()}]", default => $this->link(), }; } /** * Display the full title of the request with no links. */ public function text(): string { return match ($this->categoryName()) { 'Music' => "{$this->artistRole()->text()} – {$this->title()} [{$this->year()}]", 'Audiobooks', 'Comedy' => "{$this->title()} [{$this->year()}]", default => $this->title(), }; } public function artistFlush(): int { $this->flush(); self::$db->prepared_query(" SELECT aa.ArtistID FROM requests_artists ra INNER JOIN artists_alias aa USING (AliasID) WHERE RequestID = ? ", $this->id ); $affected = (int)self::$db->record_count(); self::$cache->delete_multi([ ...array_map(fn ($id) => "artists_requests_$id", self::$db->collect(0, false)), ]); return $affected; } public function artistRole(): ?ArtistRole\Request { if ($this->categoryName() !== 'Music') { return null; } return new ArtistRole\Request($this, new Manager\Artist()); } public function hasArtistRole(): bool { return $this->artistRole() instanceof ArtistRole\Request; } public function info(): array { if (isset($this->info)) { return $this->info; } $info = self::$db->rowAssoc(" SELECT r.UserID AS user_id, r.FillerID AS filler_id, r.TimeAdded AS created, r.TimeFilled AS fill_date, r.LastVote AS last_vote_date, r.CategoryID AS category_id, c.name AS category_name, r.Title AS title, r.Description AS description, r.Year AS year, r.Image AS image, r.CatalogueNumber AS catalogue_number, r.ReleaseType AS release_type, coalesce(rel.Name, 'Unknown') AS release_type_name, r.RecordLabel AS record_label, r.GroupID AS tgroup_id, r.TorrentID AS torrent_id, r.LogCue AS log_cue, r.Checksum AS checksum, r.BitrateList AS encoding_list, r.FormatList AS format_list, r.MediaList AS media_list, r.OCLC AS oclc FROM requests r INNER JOIN category c ON (c.category_id = r.CategoryID) LEFT JOIN release_type rel ON (rel.ID = r.ReleaseType) WHERE r.ID = ? GROUP BY r.ID ", $this->id ); self::$db->prepared_query(" SELECT rv.UserID AS user_id, SUM(rv.Bounty) AS bounty FROM requests_votes AS rv WHERE rv.RequestID = ? GROUP BY rv.UserID ORDER BY rv.Bounty DESC ", $this->id ); $info['user_vote_list'] = self::$db->to_array(false, MYSQLI_ASSOC, false); self::$db->prepared_query(" SELECT t.Name FROM requests_tags AS rt INNER JOIN tags AS t ON (t.ID = rt.TagID) WHERE rt.RequestID = ? ORDER BY rt.TagID ASC ", $this->id ); $info['tag'] = self::$db->collect('Name', false); $info['need_encoding'] = explode('|', $info['encoding_list'] ?? 'Unknown'); $info['need_format'] = explode('|', $info['format_list'] ?? 'Unknown'); $info['need_media'] = explode('|', $info['media_list'] ?? 'Unknown'); $this->info = $info; return $this->info; } /** * These fields are shared between the request and requests ajax endpoints */ public function ajaxInfo(): array { $info = $this->info(); return [ 'requestId' => $this->id(), 'requestorId' => $info['user_id'], 'timeAdded' => $info['created'], 'voteCount' => $this->userVotedTotal(), 'lastVote' => $info['last_vote_date'], 'totalBounty' => $this->bountyTotal(), 'categoryId' => $info['category_id'], 'categoryName' => $info['category_name'], 'title' => $info['title'], 'year' => (int)$info['year'], 'image' => (string)$info['image'], 'bbDescription' => $info['description'], 'description' => \Text::full_format($info['description']), 'catalogueNumber' => $info['catalogue_number'], 'recordLabel' => $info['record_label'], 'oclc' => $info['oclc'], 'releaseType' => $info['release_type'], 'releaseTypeName' => $info['release_type_name'], 'bitrateList' => array_values($this->currentEncoding()), 'formatList' => array_values($this->currentFormat()), 'mediaList' => array_values($this->currentMedia()), 'logCue' => $info['log_cue'], 'isFilled' => $info['torrent_id'] > 0, 'fillerId' => (int)$info['filler_id'], 'torrentId' => $info['torrent_id'], 'timeFilled' => (string)$info['fill_date'], 'tags' => $this->tagNameList(), ]; } public function bountyTotal(): int { return (int)array_sum(array_column($this->userIdVoteList(), 'bounty')); } public function canEditOwn(User $user): bool { return !$this->isFilled() && $user->id() == $this->userId() && $this->userVotedTotal() < 2; } public function canEdit(User $user): bool { return $this->canEditOwn($user) || $user->permittedAny('site_moderate_requests', 'site_edit_requests'); } public function canVote(User $user): bool { return !$this->isFilled() && $user->permitted('site_vote'); } public function catalogueNumber(): string { return $this->info()['catalogue_number']; } public function categoryId(): int { return $this->info()['category_id']; } public function categoryName(): string { return $this->info()['category_name']; } public function categoryImage(): string { return STATIC_SERVER . "/common/noartwork/" . CATEGORY_ICON[$this->categoryId() - 1]; } public function created(): string { return $this->info()['created']; } public function currentEncoding(): array { return $this->needEncoding('Any') ? ENCODING : array_intersect(ENCODING, $this->needEncodingList()); } public function currentFormat(): array { return $this->needFormat('Any') ? FORMAT : array_intersect(FORMAT, $this->needFormatList()); } public function currentMedia(): array { return $this->needMedia('Any') ? MEDIA : array_intersect(MEDIA, $this->needMediaList()); } public function description(): string { return $this->info()['description']; } public function encoding(): Request\Encoding { return new Request\Encoding($this->needEncoding('Any'), array_keys($this->currentEncoding())); } public function format(): Request\Format { return new Request\Format($this->needFormat('Any'), array_keys($this->currentFormat())); } public function media(): Request\Media { return new Request\Media($this->needMedia('Any'), array_keys($this->currentMedia())); } public function descriptionEncoding(): ?string { $need = $this->info()['need_encoding']; return empty($need) ? null : implode(', ', $need); } public function descriptionFormat(): ?string { $need = $this->info()['need_format']; return empty($need) ? null : implode(', ', $need); } public function descriptionLogCue(): ?string { return $this->info()['log_cue']; } public function descriptionMedia(): ?string { $need = $this->info()['need_media']; return empty($need) ? null : implode(', ', $need); } public function fillerId(): int { return $this->info()['filler_id']; } public function fillDate(): ?string { return $this->info()['fill_date']; } public function hasNewVote(): bool { return strtotime($this->lastVoteDate()) > strtotime($this->created()); } public function isFilled(): bool { return (bool)$this->info()['filler_id']; } public function image(): ?string { return $this->info()['image']; } public function lastVoteDate(): string { return $this->info()['last_vote_date']; } public function legacyFormatList(): string { return $this->info()['format_list']; } public function legacyEncodingList(): string { return $this->info()['encoding_list']; } public function legacyLogChecksum(): string { return $this->info()['checksum']; } public function legacyMediaList(): string { return $this->info()['media_list']; } public function logCue(): Request\LogCue { return new Request\LogCue( needLogChecksum: $this->needLogChecksum(), needCue: $this->needCue(), needLog: $this->needLog(), minScore: $this->needLogScore(), ); } public function needCue(): bool { return str_contains($this->descriptionLogCue(), 'Cue'); } public function needEncoding(string $encoding): bool { if ($this->needMediaList() === ['']) { return true; } return in_array($encoding, $this->needEncodingList()); } public function needEncodingList(): array { return $this->info()['need_encoding']; } public function needFormat(string $format): bool { if ($this->needMediaList() === ['']) { return true; } return in_array($format, $this->needFormatList()); } public function needFormatList(): array { return $this->info()['need_format']; } public function needLog(): bool { return str_contains($this->descriptionLogCue(), 'Log'); } public function needLogChecksum(): bool { return (bool)$this->info()['checksum']; } public function needLogScore(): int { return preg_match('/(\d+)%/', $this->descriptionLogCue(), $match) ? (int)$match[1] : 0; } public function needMedia(string $media): bool { if ($this->needMediaList() === ['']) { return true; } return in_array($media, $this->needMediaList()); } public function needMediaList(): array { return $this->info()['need_media']; } public function oclc(): ?string { return $this->info()['oclc']; } public function oclcLink(): ?string { $oclc = $this->oclc(); if (is_null($oclc) || $oclc === '') { return null; } return implode(', ', array_map(fn($id) => "{$id}", explode(',', $oclc) ) ); } public function recordLabel(): ?string { return $this->info()['record_label']; } public function releaseTypeName(): string { return $this->info()['release_type_name']; } public function releaseType(): int { return $this->info()['release_type']; } public function tagLinkList(): string { return implode(' ', array_map( fn($tag) => "$tag", $this->tagNameList() ) ); } public function tagNameList(): array { return $this->info()['tag']; } public function tagNameToSphinx(): string { return implode(' ', array_map(fn ($t) => str_replace('.', '_', $t), $this->tagNameList())); } public function tgroupId(): ?int { return $this->info()['tgroup_id']; } public function title(): string { return $this->info()['title']; } public function torrentId(): int { return $this->info()['torrent_id']; } public function userId(): int { return $this->info()['user_id']; } public function urlencodeArtist(): string { return urlencode(str_replace( ['arranged by ', 'performed by '], ['', ''], $this->artistRole()?->text() ?? '' )); } public function urlencodeTitle(): string { return urlencode(trim(preg_replace("/\([^\)]+\)/", '', $this->title()))); } public function userIdVoteList(): array { return $this->info()['user_vote_list']; } public function userVoteList(Manager\User $manager): array { $list = $this->userIdVoteList(); foreach ($list as &$user) { $user['user'] = $manager->findById($user['user_id']); } unset($user); return $list; } public function userVotedTotal(): int { return count($this->userIdVoteList()); } public function year(): int { return (int)$this->info()['year']; } public function validate(Torrent $torrent): array { $error = []; if ($this->torrentId()) { $error[] = 'This request has already been filled.'; } if (!in_array($this->categoryId(), [0, $torrent->group()->categoryId()])) { $error[] = 'This torrent is of a different category than the request. If the request is actually miscategorized, please contact staff.'; } if ($torrent->media() === 'CD' && $torrent->format() === 'FLAC') { if ($this->needLog()) { if (!$torrent->hasLogDb()) { $error[] = 'This request requires a log.'; } else { if ($torrent->logScore() < $this->needLogScore()) { $error[] = 'This torrent\'s log score (' . $torrent->logScore() . ') is too low.'; } if (!$torrent->logChecksum() && $this->needLogChecksum()) { $error[] = 'The ripping log for this torrent does not have a valid checksum.'; } } } if ($this->needCue() && !$torrent->hasCue()) { $error[] = 'This request requires a cue file.'; } } if (!$this->needMedia('Any') && !$this->needMedia($torrent->media())) { $error[] = $torrent->media() . " is not a permitted media for this request."; } if (!$this->needFormat('Any') && !$this->needFormat($torrent->format())) { $error[] = $torrent->format() . " is not an allowed format for this request."; } if ($this->needEncoding('Other')) { if (in_array($torrent->encoding(), ['24bit Lossless', 'Lossless', 'V0 (VBR)', 'V1 (VBR)', 'V2 (VBR)', 'APS (VBR)', 'APX (VBR)', '256', '320'])) { $error[] = $torrent->encoding() . " is not an allowed encoding for this request."; } } elseif (!$this->needEncoding('Any') && !$this->needEncoding($torrent->encoding())) { $error[] = $torrent->encoding() . " is not an allowed encoding for this request."; } return $error; } /** * Vote on a request (transfer upload buffer from user to a request. * * return @bool vote was successful (user had sufficient buffer) */ public function vote(User $user, int $amount): bool { self::$db->begin_transaction(); self::$db->prepared_query(" UPDATE users_leech_stats SET Uploaded = Uploaded - ? WHERE Uploaded - ? >= 0 AND UserID = ? ", $amount, $amount, $user->id() ); if (self::$db->affected_rows() == 0) { // Uploaded would turn negative self::$db->rollback(); return false; } $bounty = $amount * (1 - REQUEST_TAX); self::$db->prepared_query(" INSERT INTO requests_votes (RequestID, UserID, Bounty) VALUES (?, ?, ?) ", $this->id(), $user->id(), $bounty ); self::$db->prepared_query(" UPDATE requests SET LastVote = now() WHERE ID = ? ", $this->id ); self::$db->prepared_query(" INSERT INTO user_summary (user_id, request_vote_size, request_vote_total) SELECT rv.UserID, sum(rv.Bounty) AS size, count(*) AS total FROM requests_votes rv INNER JOIN requests r ON (r.ID = rv.RequestID) WHERE rv.UserID != r.FillerID AND rv.UserID = ? GROUP BY rv.UserID ON DUPLICATE KEY UPDATE request_vote_size = VALUES(request_vote_size), request_vote_total = VALUES(request_vote_total) ", $user->id() ); $this->updateSphinx(); self::$db->commit(); $user->flush(); return true; } /** * get all individual votes on this request */ public function voteList(): array { self::$db->prepared_query(" SELECT UserID AS user_id, Bounty AS bounty, created FROM requests_votes WHERE RequestID = ? ORDER BY created DESC, requests_votes_id DESC ", $this->id ); return self::$db->to_array(false, MYSQLI_ASSOC, false); } public function fill(User $user, Torrent $torrent): int { $bounty = $this->bountyTotal(); self::$db->begin_transaction(); self::$db->prepared_query(" UPDATE requests SET TimeFilled = now(), FillerID = ?, TorrentID = ? WHERE ID = ? ", $user->id(), $torrent->id(), $this->id ); $updated = self::$db->affected_rows(); $this->updateSphinx(); (new \SphinxqlQuery())->raw_query( sprintf(" UPDATE requests, requests_delta SET torrentid = %d, fillerid = %d WHERE id = %d ", $torrent->id(), $user->id(), $this->id ), false ); self::$db->commit(); $user->addBounty($bounty); $name = $this->title(); $message = "One of your requests — [url={$this->location()}]{$name}[/url] — has been filled." . " You can view it here: [pl]{$torrent->id()}[/pl]"; self::$db->prepared_query(" SELECT DISTINCT UserID FROM requests_votes WHERE RequestID = ? ", $this->id ); foreach (self::$db->collect(0, false) as $userId) { (new User($userId))->inbox()->createSystem("The request \"$name\" has been filled", $message); } (new Log())->general( "Request {$this->id} ($name) was filled by user {$user->label()} with the torrent {$torrent->id()} for a " . byte_format($bounty) . ' bounty.' ); $this->artistFlush(); return $updated; } public function unfill(User $admin, string $reason, Manager\Torrent $torMan): int { $bounty = $this->bountyTotal(); $filler = new User($this->fillerId()); $torrent = $torMan->findById($this->torrentId()); if (is_null($torrent)) { $torrent = $torMan->findDeletedById($this->torrentId()); } self::$db->begin_transaction(); self::$db->prepared_query(" UPDATE requests SET TorrentID = 0, FillerID = 0, TimeFilled = null, Visible = 1 WHERE ID = ? ", $this->id ); $updated = self::$db->affected_rows(); $this->updateSphinx(); $filler->addBounty(-$bounty); $filler->flush(); self::$db->commit(); (new \SphinxqlQuery())->raw_query(" UPDATE requests, requests_delta SET torrentid = 0, fillerid = 0 WHERE id = " . $this->id, false ); if ($filler->id() !== $admin->id()) { $filler->inbox()->createSystem( 'A request you filled has been unfilled', self::$twig->render('request/unfill-pm.bbcode.twig', [ 'name' => $torrent->group()->text(), 'reason' => $reason, 'request' => $this, 'viewer' => $admin, ]) ); } (new Log())->general("Request {$this->id} ({$this->title()}), with a " . byte_format($bounty) . " bounty, was unfilled by user {$admin->label()} for the reason: $reason" ); $this->artistFlush(); return $updated; } /** * Get the bounty of request, by user * * @return array keyed by user ID */ public function bounty(): array { $votes = []; foreach ($this->userIdVoteList() as $vote) { $votes[$vote['user_id']] = ['UserID' => $vote['user_id'], 'Bounty' => $vote['bounty']]; } return $votes; } /** * Get the total bounty that a user has added to a request */ public function userBounty(User $user): int { $vote = array_filter($this->userIdVoteList(), fn($r) => $r['user_id'] == $user->id()); return count($vote) ? current($vote)['bounty'] : 0; } /** * Refund the bounty of a user on a request */ public function refundBounty(User $user, string $staffName): int { $bounty = $this->userBounty($user); self::$db->begin_transaction(); self::$db->prepared_query(" DELETE FROM requests_votes WHERE RequestID = ? AND UserID = ? ", $this->id, $user->id() ); $affected = self::$db->affected_rows(); if ($affected) { $this->informRequestFillerReduction($bounty, $staffName); $message = sprintf("Refund of %s bounty (%s b) on %s by %s\n\n", byte_format($bounty), $bounty, $this->url(), $staffName ); self::$db->prepared_query(" UPDATE users_info ui INNER JOIN users_leech_stats uls USING (UserID) SET uls.Uploaded = uls.Uploaded + ?, ui.AdminComment = concat(now(), ' - ', ?, ui.AdminComment) WHERE ui.UserId = ? ", $bounty, $message, $user->id() ); $user->flush(); } self::$db->commit(); return $affected; } /** * Remove the bounty of a user on a request */ public function removeBounty(User $user, string $staffName): int { $bounty = $this->userBounty($user); self::$db->begin_transaction(); self::$db->prepared_query(" DELETE FROM requests_votes WHERE RequestID = ? AND UserID = ? ", $this->id, $user->id() ); $affected = self::$db->affected_rows(); if ($affected) { $this->informRequestFillerReduction($bounty, $staffName); $message = sprintf("Removal of %s bounty (%s b) on %s by %s\n\n", byte_format($bounty), $bounty, $this->url(), $staffName ); self::$db->prepared_query(" UPDATE users_info ui SET ui.AdminComment = concat(now(), ' - ', ?, ui.AdminComment) WHERE ui.UserId = ? ", $message, $user->id() ); $user->flush(); } self::$db->commit(); return $affected; } /** * Inform the filler of a request that their bounty was reduced */ public function informRequestFillerReduction(int $bounty, string $staffName): int { [$fillerId, $fillDate] = self::$db->row(" SELECT FillerID, date(TimeFilled) FROM requests WHERE TimeFilled IS NOT NULL AND ID = ? ", $this->id ); if (!$fillerId) { return 0; } $message = sprintf("Reduction of %s bounty (%s b) on filled request %s by %s\n\n", byte_format($bounty), $bounty, $this->url(), $staffName ); self::$db->prepared_query(" UPDATE users_info ui INNER JOIN users_leech_stats uls USING (UserID) SET uls.Uploaded = uls.Uploaded - ?, ui.AdminComment = concat(now(), ' - ', ?, ui.AdminComment) WHERE ui.UserId = ? ", $bounty, $message, $fillerId ); $affected = self::$db->affected_rows(); if ($affected) { (new User($fillerId))->inbox()->createSystem( "Bounty was reduced on a request you filled", self::$twig->render('request/bounty-reduction.bbcode.twig', [ 'bounty' => $bounty, 'fill_date' => $fillDate, 'request_url' => $this->url(), 'staff_name' => $staffName, ]) ); } return $affected; } /** * Update the sphinx requests delta table. */ public function updateSphinx(): int { self::$db->prepared_query(" REPLACE INTO sphinx_requests_delta ( ID, UserID, TimeAdded, LastVote, CategoryID, Title, Year, ReleaseType, CatalogueNumber, RecordLabel, BitrateList, FormatList, MediaList, LogCue, FillerID, TorrentID, TimeFilled, Visible, Votes, Bounty, TagList, ArtistList) SELECT ID, r.UserID, UNIX_TIMESTAMP(TimeAdded) AS TimeAdded, UNIX_TIMESTAMP(LastVote) AS LastVote, CategoryID, Title, Year, ReleaseType, CatalogueNumber, RecordLabel, BitrateList, FormatList, MediaList, LogCue, FillerID, TorrentID, UNIX_TIMESTAMP(TimeFilled) AS TimeFilled, Visible, COUNT(DISTINCT rv.UserID) AS Votes, SUM(rv.Bounty) >> 10 AS Bounty, ?, ? FROM requests AS r LEFT JOIN requests_votes AS rv ON (rv.RequestID = r.ID) WHERE r.ID = ? GROUP BY r.ID ", $this->tagNameToSphinx(), implode(' ', $this->artistRole()?->nameList() ?? []), $this->id ); $affected = self::$db->affected_rows(); $this->flush(); return $affected; } public function updateBookmarkStats(): int { self::$db->prepared_query(" SELECT UserID FROM bookmarks_requests WHERE RequestID = ? ", $this->id ); $affected = (int)self::$db->record_count(); if ($affected > 100) { // Sphinx doesn't like huge MVA updates. Update sphinx_requests_delta // and live with the <= 1 minute delay if we have more than 100 bookmarkers $this->updateSphinx(); } else { (new \SphinxqlQuery())->raw_query( "UPDATE requests, requests_delta SET bookmarker = (" . implode(',', self::$db->collect('UserID')) . ") WHERE id = {$this->id}" ); } return $affected; } public function remove(): bool { self::$db->begin_transaction(); self::$db->prepared_query("DELETE FROM requests_votes WHERE RequestID = ?", $this->id); self::$db->prepared_query("DELETE FROM requests_tags WHERE RequestID = ?", $this->id); self::$db->prepared_query("DELETE FROM requests WHERE ID = ?", $this->id); $affected = self::$db->affected_rows(); self::$db->prepared_query(" SELECT DISTINCT aa.ArtistID FROM requests_artists ra INNER JOIN artists_alias aa USING (AliasID) WHERE ra.RequestID = ? ", $this->id ); $artisIds = self::$db->collect(0, false); self::$db->prepared_query(' DELETE FROM requests_artists WHERE RequestID = ?', $this->id ); self::$db->prepared_query(" REPLACE INTO sphinx_requests_delta (ID) VALUES (?) ", $this->id ); (new \Gazelle\Manager\Comment())->remove('requests', $this->id); self::$db->commit(); foreach ($artisIds as $artistId) { self::$cache->delete_value("artists_requests_$artistId"); } self::$cache->delete_value(sprintf(Manager\Request::ID_KEY, $this->id)); $this->flush(); return $affected != 0; } }