delete_multi([ sprintf(self::CACHE_FILELIST_COUNT, $this->id), sprintf(Torrent::CACHE_KEY, $this->id), sprintf(TorrentDeleted::CACHE_KEY, $this->id), ]); unset($this->info); $this->group()->flush(); return $this; } abstract public function infoRow(): ?array; public function link(): string { return $this->group()->torrentLink($this->id); } public function groupLink(): string { return $this->group()->link(); } public function fullLink(): string { $link = $this->link(); $edition = $this->edition(); if ($edition) { $link .= " [$edition]"; } $label = $this->label(); if ($label) { $link .= " $label"; } return $link; } public function name(): string { $tgroup = $this->group(); return $tgroup->hasArtistRole() ? $tgroup->artistName() . " – " . $tgroup->name() : $tgroup->name(); } public function fullName(): string { $name = $this->group()->text(); $edition = $this->edition(); if ($edition) { $name .= " [$edition]"; } return $name; } /** * Set the viewer context, for snatched indicators etc. */ public function setViewer(User $viewer): static { $this->viewer = $viewer; return $this; } /** * Get the metadata of the torrent * * @return array of many things */ public function info(): array { if (isset($this->info)) { return $this->info; } $key = sprintf($this->isDeleted() ? TorrentDeleted::CACHE_KEY : Torrent::CACHE_KEY, $this->id); $info = self::$cache->get_value($key); if ($info === false) { $info = $this->infoRow(); if (is_null($info)) { return $this->info = []; } foreach (['last_action', 'LastReseedRequest', 'RemasterCatalogueNumber', 'RemasterRecordLabel', 'RemasterTitle', 'RemasterYear'] as $nullable) { $info[$nullable] = $info[$nullable] == '' ? null : $info[$nullable]; } foreach (['LogChecksum', 'HasCue', 'HasLog', 'HasLogDB', 'Remastered', 'Scene'] as $zerotruth) { $info[$zerotruth] = !($info[$zerotruth] == '0'); } $info['ripLogIds'] = empty($info['ripLogIds']) ? [] : array_map('intval', explode(',', $info['ripLogIds'])); $info['LogCount'] = count($info['ripLogIds']); if (!$this->isDeleted()) { self::$cache->cache_value($key, $info, ($info['Seeders'] ?? 0) > 0 ? 600 : 3600); } } if (!$this->isDeleted() && isset($this->viewer)) { $info['PersonalFL'] = $info['FreeTorrent'] == LeechType::Normal->value && $this->viewer->hasToken($this); $info['IsSnatched'] = $this->viewer->snatch()->showSnatch($this); } else { $info['PersonalFL'] = false; $info['IsSnatched'] = false; } $this->info = $info; return $this->info; } /** * Assume a torrent has not been deleted. This function is * overridden in TorrentDeleted */ public function isDeleted(): bool { return false; } public function created(): string { return $this->info()['created']; } /** * Get the torrent release description. */ public function description(): string { return $this->info()['Description'] ?? ''; } /** * Generate the edition of the torrent */ public function edition(): string { $tgroup = $this->group(); if ($tgroup->categoryName() !== 'Music') { return ''; } if ($this->isRemastered()) { $edition = [ $this->remasterRecordLabel(), $this->remasterCatalogueNumber(), $this->remasterTitle(), ]; } elseif ($tgroup->recordLabel() || $tgroup->catalogueNumber()) { $edition = [ $tgroup->recordLabel(), $tgroup->catalogueNumber(), ]; } else { $edition = [ 'Original Release', ]; } $edition = implode(' / ', array_filter($edition, fn($e) => !is_null($e))); return $this->isRemastered() ? ($this->remasterYear() . " – " . $edition) : $edition; } /** * Get the encoding of this upload. Null for non-music uploads. */ public function encoding(): ?string { return $this->info()['Encoding']; } public function fileTotal(): int { return $this->info()['FileCount']; } /** * Parse a meta filename into a more useful array structure * * @return array with the keys 'ext', 'size' and 'name' */ protected function filenameParse(string $metaname): array { if (preg_match('/^(\..*?) s(\d+)s (.+) (?:÷|' . FILELIST_DELIM . ')$/', $metaname, $match)) { return [ 'ext' => $match[1], 'size' => (int)$match[2], // transform leading blanks into hard blanks so that it shows up in HTML 'name' => preg_replace_callback( '/^(\s+)/', fn($s) => str_repeat(' ', strlen($s[1])), $match[3] ), ]; } return [ 'ext' => null, 'size' => 0, 'name' => null, ]; } /** * Get the files of this upload * @return array of ['file', 'ext', 'size'] for each file */ public function fileList(): array { return $this->info['file_list'] ??= $this->rebuildFileList(); } /** * The list of files in very large torrents (e.g. Applications) can * exceed 1 MiB, which is beyond the default size of objects cached * by memcached. To work around this, file lists are sliced up into * 1 MiB chunks, and then rebuilt upon request. */ protected function rebuildFileList(): array { $fileList = ''; $rebuild = true; $count = self::$cache->get_value(sprintf(self::CACHE_FILELIST_COUNT, $this->id)); if ($count !== false) { // we have evidence that the file list has been stored, attempt to retrieve it $rebuild = false; for ($c = 0; $c < $count; $c++) { $chunk = self::$cache->get_value(sprintf(self::CACHE_FILELIST_CHUNK, $this->id, $c)); if ($chunk === false) { // chunk has been evicted or expired $rebuild = true; break; } $fileList .= $chunk; } } if ($rebuild) { $fileList = (string)self::$db->scalar(" SELECT FileList FROM torrents WHERE ID = ? ", $this->id ); $chunkSize = (int)(1024 ** 2); $chunkTotal = (int)ceil(strlen($fileList) / $chunkSize); for ($c = 0; $c < $chunkTotal; $c++) { self::$cache->cache_value( sprintf(self::CACHE_FILELIST_CHUNK, $this->id, $c), substr($fileList, $c * $chunkSize, $chunkSize), 0 ); } self::$cache->cache_value( sprintf(self::CACHE_FILELIST_COUNT, $this->id), $chunkTotal, 0 ); } return array_map( fn ($f) => $this->filenameParse($f), explode("\n", $fileList) ); } /** * Aggregate the primary media file types per extension * * @return array of array of [ac3, flac, m4a, mp3, ...] => count */ public function fileListPrimaryMap(): array { $map = []; foreach ($this->fileList() as $file) { if (is_null($file['ext'])) { continue; } $ext = substr($file['ext'], 1); // skip over period if (preg_match('/^' . PRIMARY_EXT_REGEXP . '$/', $ext)) { if (!isset($map[$ext])) { $map[$ext] = 0; } ++$map[$ext]; } } return $map; } public function fileListPrimaryTotal(): int { return (int)array_reduce( array_values($this->fileListPrimaryMap()), fn ($total, $n) => $total += $n ); } public function fileListNonPrimarySize(): int { $size = 0; foreach ($this->fileList() as $file) { if (!preg_match('/^\.' . PRIMARY_EXT_REGEXP . '$/', (string)$file['ext'])) { $size += (int)$file['size']; } } return $size; } /** * Create a string that contains file info in the old format for the API * * @return string with the format 'NAME{{{SIZE}}}|||NAME{{{SIZE}}}|||...' */ public function fileListLegacyAPI(): string { return implode('|||', array_map( fn ($file) => $file['name'] . '{{{' . $file['size'] . '}}}', $this->fileList() )); } /** * Get the format of this upload. Null for non-music uploads. */ public function format(): ?string { return $this->info()['Format']; } public function leechType(): LeechType { return match ($this->info()['FreeTorrent']) { LeechType::Free->value => LeechType::Free, LeechType::Neutral->value => LeechType::Neutral, default => LeechType::Normal, }; } public function leechReason(): LeechReason { return match ($this->info()['FreeLeechType']) { LeechReason::AlbumOfTheMonth->value => LeechReason::AlbumOfTheMonth, LeechReason::Permanent->value => LeechReason::Permanent, LeechReason::Showcase->value => LeechReason::Showcase, LeechReason::StaffPick->value => LeechReason::StaffPick, default => LeechReason::Normal, }; } /** * Group ID this torrent belongs to */ public function groupId(): int { return $this->info()['GroupID']; } /** * Get the torrent group in which this torrent belongs. */ public function group(): TGroup { if (!isset($this->tgroup)) { $this->tgroup = new TGroup($this->groupId()); if (isset($this->viewer)) { $this->tgroup->setViewer($this->viewer); } } return $this->tgroup; } /** * Does it have a .cue file? */ public function hasCue(): bool { return $this->info()['HasCue']; } /** * Does it have logs? */ public function hasLog(): bool { return $this->info()['HasLog']; } /** * Does it have uploaded logs? */ public function hasLogDb(): bool { return $this->info()['HasLogDB']; } /** * It is possible that a torrent can be orphaned from a group, in which case the * TGroup property cannot be instantiated, even though the Torrent object can. * This method can be used to verify that group() can be called. */ public function hasTGroup(): bool { return (new Manager\TGroup())->findById($this->groupId()) instanceof TGroup; } /** * The infohash of this torrent */ public function infohash(): string { return bin2hex($this->info()['info_hash']); } /** * The infohash as expected by Ocelot */ public function infohashEncoded(): string { return rawurlencode($this->info()['info_hash']); } public function isFreeleech(): bool { return $this->info()['FreeTorrent'] == LeechType::Free->value; } public function isFreeleechPersonal(): bool { return $this->info()['PersonalFL']; } public function isNeutralleech(): bool { return $this->info()['FreeTorrent'] == LeechType::Neutral->value; } /* Is this a Perfect Flac? * - CD with 100% rip * - FLAC from any other media */ public function isPerfectFlac(): bool { return $this->format() === 'FLAC' && ( ($this->media() === 'CD' && $this->logScore() === 100) || (in_array($this->media(), ['Vinyl', 'WEB', 'DVD', 'Soundboard', 'Cassette', 'SACD', 'BD', 'DAT'])) ); } /* Is this a Perfecter Flac? * - `CD with 100% rip * - FLAC from DAT or Cassette * - 24bit FLAC from any other media */ public function isPerfecterFlac(): bool { return $this->format() === 'FLAC' && ( ($this->media() === 'CD' && $this->logScore() === 100) || ( $this->encoding() === '24bit Lossless' && (in_array($this->media(), ['Vinyl', 'WEB', 'DVD', 'Soundboard', 'Cassette', 'SACD', 'BD', 'DAT'])) ) || (in_array($this->media(), ['Cassette', 'DAT'])) ); } /** * Is this a remastered release? */ public function isRemastered(): bool { return $this->info()['Remastered'] ?? false; } public function isRemasteredUnknown(): bool { return $this->isRemastered() && !$this->remasterYear(); } public function isScene(): bool { return $this->info()['Scene']; } /** * TO BE USED JUDICIOUSLY - SITE CODE SHOULD NEVER CALL THIS * Is this being actively seeded *right now*? */ public function isSeedingRealtime(): bool { return (bool)self::$db->scalar(" SELECT 1 FROM xbt_files_users WHERE remaining = 0 and active = 1 and fid = ? ", $this->id ); } public function lastActiveDate(): ?string { return $this->info()['last_action']; } public function lastReseedRequestDate(): ?string { return $this->info()['LastReseedRequest']; } /** * The number of leechers of this torrent */ public function leecherTotal(): int { return $this->info()['Leechers']; } /** * The log score of this torrent */ public function logChecksum(): bool { return $this->info()['LogChecksum']; } /** * The log score of this torrent */ public function logScore(): int { return $this->info()['LogScore']; } public function logfileList(\Gazelle\File\RipLog $ripFiler, \Gazelle\File\RipLogHTML $htmlFiler): array { self::$db->prepared_query(" SELECT LogID AS id, Score, `Checksum`, Adjusted, AdjustedBy, AdjustedScore, AdjustedChecksum, AdjustmentReason, coalesce(AdjustmentDetails, 'a:0:{}') AS AdjustmentDetails, Details FROM torrents_logs WHERE TorrentID = ? ", $this->id ); $list = self::$db->to_array(false, MYSQLI_ASSOC, false); foreach ($list as &$log) { $log['has_riplog'] = $ripFiler->exists([$this->id, $log['id']]); $log['html_log'] = $htmlFiler->get([$this->id, $log['id']]); $log['adjustment_details'] = unserialize($log['AdjustmentDetails']); $log['adjusted'] = ($log['Adjusted'] === '1'); $log['adjusted_checksum'] = ($log['AdjustedChecksum'] === '1'); $log['checksum'] = ($log['Checksum'] === '1'); $log['details'] = empty($log['Details']) ? [] : explode("\r\n", trim($log['Details'])); if ($log['adjusted'] && $log['checksum'] !== $log['adjusted_checksum']) { $log['details'][] = 'Bad/No Checksum(s)'; } } return $list; } /** * The media of this torrent. Will be null for non-music uploads. */ public function media(): ?string { return $this->info()['Media']; } public function path(): string { return $this->info()['FilePath']; } public function remasterCatalogueNumber(): ?string { return $this->info()['RemasterCatalogueNumber']; } public function remasterRecordLabel(): ?string { return $this->info()['RemasterRecordLabel']; } public function remasterTitle(): ?string { return $this->info()['RemasterTitle']; } public function remasterYear(): ?int { return $this->info()['RemasterYear']; } public function remasterTuple(): string { return implode('!!', [ $this->media(), $this->isRemastered(), $this->remasterTitle(), $this->remasterYear(), $this->remasterRecordLabel(), $this->remasterCatalogueNumber(), ]); } /** * Get the reports associated with this torrent * * @return array of ids of \Gazelle\Torrent\Report */ public function reportIdList(User $viewer): array { if ($this->isDeleted()) { return []; } $key = sprintf(self::CACHE_REPORTLIST, $this->id()); $list = self::$cache->get_value($key); if ($list === false) { $qid = self::$db->get_query_id(); self::$db->prepared_query(" SELECT r.ID AS id, r.ReporterID AS reporter_id, trc.is_invisible FROM reportsv2 r INNER JOIN torrent_report_configuration trc ON (trc.type = r.Type) WHERE r.Status != 'Resolved' AND r.TorrentID = ? ", $this->id ); $list = self::$db->to_array(false, MYSQLI_ASSOC, false); self::$db->set_query_id($qid); self::$cache->cache_value($key, $list, 7200); } if (!$viewer->isStaff()) { $list = array_filter($list, fn($r) => $r['is_invisible'] == 0 || $r['reporter_id'] == $viewer->id()); } return array_column($list, 'id'); } public function reportTotal(User $viewer): int { return count($this->reportIdList($viewer)); } public function ripLogIdList(): array { return $this->info()['ripLogIds']; } public function seederTotal(): int { return $this->info()['Seeders']; } /** * The size (in bytes) of this upload */ public function size(): int { return $this->info()['Size']; } public function snatchTotal(): int { return $this->info()['Snatched']; } /** * How many tokens are required to download for free? */ public function tokenCount(): int { return (int)ceil($this->size() / BYTES_PER_FREELEECH_TOKEN); } public function unseeded(): bool { return $this->seederTotal() === 0; } /** * Was it uploaded less than an hour ago? (Request fill grace period) */ public function isUploadGracePeriod(): bool { return strtotime($this->created()) > date('U') - 3600; } /** * Was it active more then 14 days ago? If never active has it been 3 days? (Reseed grace period) */ public function isReseedRequestAllowed(): bool { $lastRequestDate = $this->lastReseedRequestDate(); $lastActiveDate = $this->lastActiveDate(); $lastActiveEpoch = is_null($lastActiveDate) ? 0 : (int)strtotime($lastActiveDate); $createdEpoch = (int)strtotime($this->created()); return match (true) { !$lastActiveEpoch && !$lastRequestDate => (time() >= strtotime(RESEED_NEVER_ACTIVE_TORRENT . ' days', $createdEpoch)), !$lastRequestDate => (time() >= strtotime(RESEED_TORRENT . 'days', $lastActiveEpoch)), default => false, }; } /** * The uploader ID of this torrent */ public function uploaderId(): int { return $this->info()['UserID']; } /** * The uploader of this torrent */ public function uploader(): User { return new User($this->uploaderId()); } /**** TORRENT FLAG TABLE METHODS ****/ public function hasFlag(TorrentFlag $flag): bool { return isset($this->info()['attr'][$flag->value]); } public function addFlag(TorrentFlag $flag, User $user): int { self::$db->prepared_query(" INSERT IGNORE INTO torrent_has_attr (TorrentID, TorrentAttrID, UserID) VALUES (?, (SELECT ID FROM torrent_attr WHERE Name = ?), ?) ", $this->id, $flag->value, $user->id() ); $this->flush(); return self::$db->affected_rows(); } public function removeFlag(TorrentFlag $flag): int { self::$db->prepared_query(" DELETE FROM torrent_has_attr WHERE TorrentID = ? AND TorrentAttrID = (SELECT ID FROM torrent_attr WHERE Name = ?) ", $this->id, $flag->value ); $this->flush(); return self::$db->affected_rows(); } public function hasUploadLock(): bool { return (bool)self::$cache->get_value("torrent_{$this->id}_lock"); } public function lockUpload(): void { self::$cache->cache_value(sprintf(self::CACHE_LOCK, $this->id), true, 120); } public function unlockUpload(): void { self::$cache->delete_value(sprintf(self::CACHE_LOCK, $this->id)); } /**** LABEL METHODS (e.g. [WEB / FLAC / Lossless]) ****/ protected function labelElement($class, $text): string { return sprintf('%s', $class, $text, $text ); } public function shortLabelList(): array { $info = $this->info(); $label = []; if (!empty($info['Media'])) { $label[] = $info['Media']; } if (!empty($info['Format'])) { $label[] = $info['Format']; } if (!empty($info['Encoding'])) { $label[] = $info['Encoding']; } if ($info['Media'] === 'CD') { if ($info['HasLog']) { if (!$info['HasLogDB']) { $label[] = 'Log'; } else { if (isset($this->viewer) && $this->viewer->isStaff()) { $label[] = "id}&groupid={$this->groupId()}\">Log ({$info['LogScore']}%)"; } else { $label[] = "Log ({$info['LogScore']}%)"; } } } if ($info['HasCue']) { $label[] = 'Cue'; } } if ($info['Scene']) { $label[] = 'Scene'; } return $label; } public function shortLabel(): string { return implode(' / ', $this->shortLabelList()); } public function shortLabelLink(): string { $short = $this->shortLabel(); if (!$short) { return ''; } // deal with nested log link // we have "............" // we want "............" if (str_contains($short, ')#', "\\1url()}\">", $short); } return "url()}\">[$short]"; } public function labelList(?User $viewer = null): array { $info = $this->info(); $extra = []; if ($viewer?->snatch()?->showSnatch($this)) { $extra[] = $this->labelElement('tl_snatched', 'Snatched!'); } if ($info['PersonalFL']) { $extra[] = $this->labelElement('tl_free tl_personal', 'Personal Freeleech!'); } else { $leechType = $this->leechType(); if ($leechType == LeechType::Free) { $extra[] = $this->labelElement('tl_free', 'Freeleech!'); } elseif ($leechType == LeechType::Neutral) { $extra[] = $this->labelElement('tl_free tl_neutral', 'Neutral Leech!'); } } if ($info['Media'] === 'CD' && $info['HasLog'] && $info['HasLogDB'] && !$info['LogChecksum']) { $extra[] = $this->labelElement('tl_notice', 'Bad/Missing Checksum'); } foreach (TorrentFlag::cases() as $flag) { if ($this->hasFlag($flag)) { $extra[] = $this->labelElement("tl_{$flag->labelClass()} tl_{$flag->value}", $flag->label()); } } if ($viewer && $this->reportTotal($viewer)) { $extra[] = $this->labelElement('tl_reported', 'Reported'); } return $extra; } public function label(?User $viewer = null): string { $short = $this->shortLabel(); $extra = $this->labelList($viewer); if ($short) { return "[$short]" . ($extra ? ' ' . implode(' / ', $extra) : ''); } elseif ($extra) { return implode(' / ', $extra); } return ''; } }