From d3a87015b12d26ff5fbc1e5bb95f213104ad7d1e Mon Sep 17 00:00:00 2001 From: itismadness Date: Tue, 2 Sep 2025 01:57:50 +0000 Subject: [PATCH] Add user_torrents ajax endpoint --- app/ArtistRole.php | 26 ++++++++++ app/Enum/UserTorrentSearch.php | 10 ++-- app/Json/UserTorrents.php | 91 +++++++++++++++++++++++++++++++++ app/Search/UserTorrent.php | 79 +++++++++++++++++++++++----- docs/07-API.md | 58 +++++++++++++++++++++ sections/ajax/index.php | 3 ++ sections/ajax/user_torrents.php | 48 +++++++++++++++++ 7 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 app/Json/UserTorrents.php create mode 100644 sections/ajax/user_torrents.php diff --git a/app/ArtistRole.php b/app/ArtistRole.php index e47822432..9952e3c29 100644 --- a/app/ArtistRole.php +++ b/app/ArtistRole.php @@ -80,6 +80,32 @@ abstract class ArtistRole extends Base { ]; } + /** + * Returns the id of the first artist that would be shown in the renderRole + * display. If it would be shown as "various ...", then it's the first + * artist of that role type. + * + * @return int|null + */ + public function primaryId(): int|null { + $roleList = $this->roleList(); + $composerCount = count($roleList['composer'] ?? []); + $conductorCount = count($roleList['conductor'] ?? []); + $djCount = count($roleList['dj'] ?? []); + $mainCount = count($roleList['main'] ?? []); + + if ($djCount) { + return $roleList['dj'][0]['id']; + } elseif ($composerCount) { + return $roleList['composer'][0]['id']; + } elseif ($mainCount) { + return $roleList['main'][0]['id']; + } elseif ($conductorCount) { + return $roleList['conductor'][0]['id']; + } + return null; + } + protected function renderRole(int $mode): string { $roleList = $this->roleList(); $arrangerCount = count($roleList['arranger'] ?? []); diff --git a/app/Enum/UserTorrentSearch.php b/app/Enum/UserTorrentSearch.php index f48495553..7368d4764 100644 --- a/app/Enum/UserTorrentSearch.php +++ b/app/Enum/UserTorrentSearch.php @@ -3,7 +3,11 @@ namespace Gazelle\Enum; enum UserTorrentSearch: string { - case seeding = 'seeding'; - case snatched = 'snatched'; - case uploaded = 'uploaded'; + case downloaded = 'downloaded'; + case leeching = 'leeching'; + case seeding = 'seeding'; + case snatched = 'snatched'; + case snatchedUnseeded = 'snatched-unseeded'; + case uploaded = 'uploaded'; + case uploadedUnseeded = 'uploaded-unseeded'; } diff --git a/app/Json/UserTorrents.php b/app/Json/UserTorrents.php new file mode 100644 index 000000000..964c360ea --- /dev/null +++ b/app/Json/UserTorrents.php @@ -0,0 +1,91 @@ +type = $type; + return $this; + } + + public function setLimit(int $limit): static { + $this->limit = $limit; + return $this; + } + + public function setOffset(int $offset): static { + $this->offset = $offset; + return $this; + } + + public function payload(): array { + switch ($this->type) { + case 'downloaded': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::downloaded); + break; + case 'leeching': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::leeching); + break; + case 'seeding': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::seeding); + break; + case 'snatched': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::snatched); + break; + case 'snatched-unseeded': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::snatchedUnseeded); + break; + case 'uploaded': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::uploaded); + break; + case 'uploaded-unseeded': + $userTorrent = new UserTorrent($this->user, UserTorrentSearch::uploadedUnseeded); + break; + default: + json_error("bad type"); + } + $userTorrent->setLimit($this->limit)->setOffset($this->offset); + + return [ + "$this->type" => array_reduce( + $userTorrent->idList(), + function ($acc, $id) { + $torrent = $this->torMan->findById($id); + if ($torrent) { + $item = [ + 'groupId' => $torrent->groupId(), + 'torrentId' => $torrent->id(), + 'name' => $torrent->group()->name(), + 'torrentSize' => $torrent->size(), + ]; + + if ($torrent->group()->hasArtistRole()) { + // artistId and artistName are returned mostly for compatibility with RED + // as people should probably just use `artists` array principally. + $item['artistId'] = $torrent->group()->artistRole()?->primaryId(); + $item['artistName'] = $torrent->group()->artistName(); + $item['artists'] = static::artistPayload($torrent->group()); + } + $acc[] = $item; + } + return $acc; + }, + [], + ), + 'total' => $userTorrent->total(), + ]; + } +} diff --git a/app/Search/UserTorrent.php b/app/Search/UserTorrent.php index 0bd2ba009..c8a4a8d01 100644 --- a/app/Search/UserTorrent.php +++ b/app/Search/UserTorrent.php @@ -11,6 +11,9 @@ use Gazelle\Enum\UserTorrentSearch; */ class UserTorrent extends \Gazelle\Base { + private int $limit; + private int $offset; + public function __construct( protected \Gazelle\User $user, protected UserTorrentSearch $type, @@ -20,22 +23,74 @@ class UserTorrent extends \Gazelle\Base { return $this->type->value; } - public function idList(): array { - self::$db->prepared_query(" - SELECT DISTINCT t.ID - FROM torrents AS t " + public function setLimit(int $limit): static { + $this->limit = $limit; + return $this; + } + + public function setOffset(int $offset): static { + $this->offset = $offset; + return $this; + } + + protected function sql(): string { + return "FROM torrents AS t " . match ($this->type) { + UserTorrentSearch::downloaded => " + INNER JOIN users_downloads AS ud ON (ud.TorrentID = t.ID) + WHERE ud.UserID = ? + ORDER BY ud.Time DESC", + UserTorrentSearch::leeching => " + INNER JOIN xbt_files_users AS xfu ON (xfu.fid = t.ID) + WHERE xfu.active = 1 AND xfu.Remaining > 0 AND xfu.uid = ? + ORDER BY from_unixtime(xfu.mtime - xfu.timespent) DESC", UserTorrentSearch::seeding => " INNER JOIN xbt_files_users AS xfu ON (t.ID = xfu.fid) - WHERE xfu.remaining = 0 - AND xfu.uid = ?", + WHERE xfu.remaining = 0 AND xfu.uid = ? + ORDER BY from_unixtime(xfu.mtime - xfu.timespent) DESC", UserTorrentSearch::snatched => " - INNER JOIN xbt_snatched AS x ON (t.ID = x.fid) - WHERE x.uid = ?", - default => - "WHERE t.UserID = ?", - }, $this->user->id - ); + INNER JOIN xbt_snatched AS xs ON (t.ID = xs.fid) + WHERE xs.uid = ? + ORDER BY from_unixtime(xs.tstamp) DESC", + UserTorrentSearch::snatchedUnseeded => " + INNER JOIN xbt_snatched AS xs ON (xs.fid = t.ID) + LEFT JOIN xbt_files_users AS xfu USING (uid, fid) + WHERE xfu.fid IS NULL AND xs.uid = ? + ORDER BY from_unixtime(xs.tstamp) DESC", + UserTorrentSearch::uploadedUnseeded => " + LEFT JOIN xbt_files_users AS xfu ON (xfu.fid = t.ID AND xfu.uid = t.UserID) + WHERE xfu.fid IS NULL AND t.UserID = ? + ORDER BY t.created DESC", + // UserTorrentSearch::uploaded + default => " + WHERE t.UserID = ? + ORDER BY t.created DESC", + }; + } + + public function total(): int { + return (int)self::$db->scalar(" + SELECT COUNT(DISTINCT t.ID) + {$this->sql()} + ", $this->user->id); + } + + public function idList(): array { + $sql = " + SELECT DISTINCT t.ID + {$this->sql()}"; + + $args = [$this->user->id]; + if (isset($this->limit)) { + $sql .= "\nLIMIT ?"; + $args[] = $this->limit; + } + if (isset($this->offset)) { + $sql .= "\nOFFSET ?"; + $args[] = $this->offset; + } + + self::$db->prepared_query($sql, ...$args); return self::$db->collect(0); } } diff --git a/docs/07-API.md b/docs/07-API.md index 2af4b69fd..798b0595c 100644 --- a/docs/07-API.md +++ b/docs/07-API.md @@ -46,6 +46,7 @@ exists for the sake of interoperability and may go away in the future. * [Upload](#upload) * [Download](#download) * [Add Log](#add-log) + * [User Torrents](#user-torrents) * [Better](#better) * [Logchecker](#logchecker) * [Requests](#requests) @@ -1538,6 +1539,63 @@ __Note__: Must be the uploader of the torrent or moderator to add logs } ``` +### User Torrents + +**URL:** +`ajax.php?action=user_torrents&userid=&type=` + +__Note__: Must be the uploader of the torrent or moderator to add logs + +**GET Arguments:** + +`userid` - The user id to get torrents for (can also use `id`) +`type` - Type of torrents to display (options are `downloaded`, `leeching`, `seeding`, `snatched`, `snatched-unseeded`, `uploaded`, `uploaded-unseeded`) +`limit` - (Optional) Number of results to display (default: 500) +`offset` - (Optional) Number of results to offset by (default: 0) +`page` - (Optional) Page of results to show (default: 1) + +__NOTE__: You can only provide one of `offset` or `page`. Offset is a number of +rows to skip before starting to collect the result set, while page is the page +number of results to retrieve, where `(page - 1) * limit == offset`. + +**Response format:** + +```json +{ + "status": "success", + "response": { + "uploaded": [ + { + "groupId": 1, + "torrentId": 1, + "name": "Foo", + "torrentSize": 12345, + "artistId": 1, + "artistName": "Bar", + "artists": { + "artists": [ + { + "id": 1, + "aliasid": 1, + "name": "Bar", + } + ] + }, + }, + ], + "total": 1 + }, + "info": { + "source": "Gazelle Dev", + "version": 1 + } +} +``` + +__NOTE__: For a torrent that has many main artists, `artistName` will be +"Various ..." and the `artistId` will be the ID of the first main artist. It is +recommended to use the `artists` field to get artist information. + ## Better **URL:** diff --git a/sections/ajax/index.php b/sections/ajax/index.php index 101abdd78..115dd02b4 100644 --- a/sections/ajax/index.php +++ b/sections/ajax/index.php @@ -257,6 +257,9 @@ switch ($Action) { case 'add_log': include_once __DIR__ . '/add_log.php'; break; + case 'user_torrents': + include_once __DIR__ . '/user_torrents.php'; + break; default: // If they're screwing around with the query string json_error("failure"); diff --git a/sections/ajax/user_torrents.php b/sections/ajax/user_torrents.php new file mode 100644 index 000000000..f0f7588a9 --- /dev/null +++ b/sections/ajax/user_torrents.php @@ -0,0 +1,48 @@ +findById((int)($_GET['userid'] ?? $_GET['id'] ?? 0)); +if (is_null($user)) { + json_error("bad userid"); +} +if (!$user->propertyVisible($Viewer, str_replace('-unseeded', '', $type))) { + json_error('user has hidden this'); +} + +echo new Json\UserTorrents($user, $Viewer) + ->setType($type) + ->setLimit($limit) + ->setOffset($offset) + ->response();