Files
ops-Gazelle/app/TorrentAbstract.php
2025-09-10 13:01:53 +02:00

838 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Gazelle;
use Gazelle\Enum\LeechReason;
use Gazelle\Enum\LeechType;
use Gazelle\Enum\TorrentFlag;
use Gazelle\File\RipLog as RipLog;
use Gazelle\File\RipLogHTML as RipLogHTML;
abstract class TorrentAbstract extends BaseAttrObject {
final public const CACHE_LOCK = 'torrent_lock_%d';
final public const CACHE_REPORTLIST = 't_rpt2_%d';
final public const CACHE_FILELIST_COUNT = 't_filelist_t_%d';
final public const CACHE_FILELIST_CHUNK = 't_filelist_c_%d_%d';
protected TGroup $tgroup;
public function flush(): static {
self::$cache->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;
}
/**
* 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() && $this->requestContext()->hasViewer()) {
$viewer = $this->requestContext()->viewer();
$info['PersonalFL'] = $info['FreeTorrent'] == LeechType::Normal->value
&& $viewer->hasToken($this);
$info['IsSnatched'] = $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 (.+) (?:&divide;|' . 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('&nbsp;', 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 = 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 . '$/i', $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 . '$/i', (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 {
return $this->tgroup ??= new TGroup($this->groupId());
}
/**
* 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(): 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);
foreach ($list as &$log) {
$log['has_riplog'] = new RipLog($this->id, $log['id'])->exists();
$log['html_log'] = new RipLogHTML($this->id, $log['id'])->get();
$log['adjustment_details'] = unserialize($log['AdjustmentDetails']);
$log['adjusted'] = ($log['Adjusted'] === '1');
$log['adjusted_checksum'] = ($log['AdjustedChecksum'] === '1');
$log['checksum'] = ($log['Checksum'] === '1');
$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 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);
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() / new Manager\SiteOption()->freeTokenSize()
);
}
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)
VALUES (?, (SELECT ID FROM torrent_attr WHERE Name = ?))
", $this->id, $flag->value
);
$affected = self::$db->affected_rows();
if ($affected) {
$this->logger()->torrent(
$this,
$user,
"\"{$flag->label()}\" flag added to torrent {$this->id}",
);
$this->flush();
}
return $affected;
}
public function removeFlag(TorrentFlag $flag, User $user): 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
);
$affected = self::$db->affected_rows();
if ($affected) {
$this->logger()->torrent(
$this,
$user,
"\"{$flag->label()}\" flag removed from torrent {$this->id}",
);
$this->flush();
}
return $affected;
}
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('<strong class="torrent_label tooltip %s" title="%s" style="white-space: nowrap;">%s</strong>',
$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[] = '<span class="tooltip" style="float: none" title="There is a logifile in the torrent, but it has not been uploaded to the site!">Log</span>';
} else {
if ($this->requestContext()->viewer()->isStaff()) {
$label[] = "<a href=\"torrents.php?action=viewlog&torrentid={$this->id}&groupid={$this->groupId()}\">Log ({$info['LogScore']}%)</a>";
} 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 "....<a href="x">....</a>...."
// we want "<a href="y">....</a><a href="x">....</a><a href="y">....</a>"
if (str_contains($short, '<a href=')) {
$short = preg_replace('#(<a href=.*</a>)#', "</a>\\1<a href=\"{$this->url()}\">", $short);
}
return "<a href=\"{$this->url()}\">[$short]</a>";
}
public function labelList(User|null $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|null $viewer = null): string {
$short = $this->shortLabel();
$extra = $this->labelList($viewer);
if ($short) {
return "[$short]" . ($extra ? ' ' . implode(' / ', $extra) : '');
} elseif ($extra) {
return implode(' / ', $extra);
}
return '';
}
/**
* Get array of the hashes of all logfiles for a torrent
* @return array<string>
*/
public function logfileHashList(): array {
self::$db->prepared_query("
SELECT LogID FROM torrents_logs WHERE TorrentID = ?
", $this->id
);
$hashes = [];
foreach (self::$db->collect(0) as $logId) {
$hashes[] = new RipLog($this->id, $logId)->hash();
}
return $hashes;
}
}