1use std::sync::Arc;
3
4use serde_derive::{Deserialize, Serialize};
5use tracing::debug;
6use url::Url;
7
8use super::authorization::{self, ACTION};
9use super::category::DbCategoryRepository;
10use crate::config::Configuration;
11use crate::databases::database::{Database, Error, Sorting};
12use crate::errors::ServiceError;
13use crate::models::category::CategoryId;
14use crate::models::info_hash::InfoHash;
15use crate::models::response::{DeletedTorrentResponse, TorrentResponse, TorrentsResponse};
16use crate::models::torrent::{Metadata, TorrentId, TorrentListing};
17use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile};
18use crate::models::torrent_tag::{TagId, TorrentTag};
19use crate::models::user::UserId;
20use crate::services::user::Repository;
21use crate::tracker::statistics_importer::StatisticsImporter;
22use crate::utils::parse_torrent::decode_and_validate_torrent_file;
23use crate::{tracker, AsCSV};
24
25pub struct Index {
26 configuration: Arc<Configuration>,
27 tracker_statistics_importer: Arc<StatisticsImporter>,
28 tracker_service: Arc<tracker::service::Service>,
29 user_repository: Arc<Box<dyn Repository>>,
30 category_repository: Arc<DbCategoryRepository>,
31 torrent_repository: Arc<DbTorrentRepository>,
32 torrent_info_hash_repository: Arc<DbCanonicalInfoHashGroupRepository>,
33 torrent_info_repository: Arc<DbTorrentInfoRepository>,
34 torrent_file_repository: Arc<DbTorrentFileRepository>,
35 torrent_announce_url_repository: Arc<DbTorrentAnnounceUrlRepository>,
36 torrent_tag_repository: Arc<DbTorrentTagRepository>,
37 torrent_listing_generator: Arc<DbTorrentListingGenerator>,
38 authorization_service: Arc<authorization::Service>,
39}
40
41pub struct AddTorrentRequest {
42 pub title: String,
43 pub description: String,
44 pub category_name: String,
45 pub tags: Vec<TagId>,
46 pub torrent_buffer: Vec<u8>,
47}
48
49pub struct AddTorrentResponse {
50 pub torrent_id: TorrentId,
51 pub canonical_info_hash: String,
52 pub info_hash: String,
53}
54
55#[derive(Debug, Deserialize)]
57pub struct ListingRequest {
58 pub page_size: Option<u8>,
59 pub page: Option<u32>,
60 pub sort: Option<Sorting>,
61 pub categories: Option<String>,
63 pub tags: Option<String>,
65 pub search: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
70pub struct ListingSpecification {
71 pub search: Option<String>,
72 pub categories: Option<Vec<String>>,
73 pub tags: Option<Vec<String>>,
74 pub sort: Sorting,
75 pub offset: u64,
76 pub page_size: u8,
77}
78
79impl Index {
80 #[allow(clippy::too_many_arguments)]
81 #[must_use]
82 pub fn new(
83 configuration: Arc<Configuration>,
84 tracker_statistics_importer: Arc<StatisticsImporter>,
85 tracker_service: Arc<tracker::service::Service>,
86 user_repository: Arc<Box<dyn Repository>>,
87 category_repository: Arc<DbCategoryRepository>,
88 torrent_repository: Arc<DbTorrentRepository>,
89 torrent_info_hash_repository: Arc<DbCanonicalInfoHashGroupRepository>,
90 torrent_info_repository: Arc<DbTorrentInfoRepository>,
91 torrent_file_repository: Arc<DbTorrentFileRepository>,
92 torrent_announce_url_repository: Arc<DbTorrentAnnounceUrlRepository>,
93 torrent_tag_repository: Arc<DbTorrentTagRepository>,
94 torrent_listing_repository: Arc<DbTorrentListingGenerator>,
95 authorization_service: Arc<authorization::Service>,
96 ) -> Self {
97 Self {
98 configuration,
99 tracker_statistics_importer,
100 tracker_service,
101 user_repository,
102 category_repository,
103 torrent_repository,
104 torrent_info_hash_repository,
105 torrent_info_repository,
106 torrent_file_repository,
107 torrent_announce_url_repository,
108 torrent_tag_repository,
109 torrent_listing_generator: torrent_listing_repository,
110 authorization_service,
111 }
112 }
113
114 pub async fn add_torrent(
133 &self,
134 add_torrent_req: AddTorrentRequest,
135 maybe_user_id: Option<UserId>,
136 ) -> Result<AddTorrentResponse, ServiceError> {
137 let Some(user_id) = maybe_user_id else {
138 return Err(ServiceError::UnauthorizedActionForGuests);
139 };
140
141 self.authorization_service
142 .authorize(ACTION::AddTorrent, maybe_user_id)
143 .await?;
144
145 let metadata = self.validate_and_build_metadata(&add_torrent_req).await?;
146
147 let (mut torrent, original_info_hash) = decode_and_validate_torrent_file(&add_torrent_req.torrent_buffer)?;
148
149 self.customize_announcement_info_for(&mut torrent).await;
150
151 self.canonical_info_hash_group_checks(&original_info_hash, &torrent.canonical_info_hash())
152 .await?;
153
154 let torrent_id = self
155 .torrent_repository
156 .add(&original_info_hash, &torrent, &metadata, user_id)
157 .await?;
158
159 self.import_torrent_statistics_from_tracker(torrent_id, &torrent.canonical_info_hash())
163 .await;
164
165 if let Err(e) = self
172 .tracker_service
173 .whitelist_info_hash(torrent.canonical_info_hash_hex())
174 .await
175 {
176 drop(self.torrent_repository.delete(&torrent_id).await);
178 return Err(e.into());
179 }
180
181 Ok(AddTorrentResponse {
184 torrent_id,
185 canonical_info_hash: torrent.canonical_info_hash_hex(),
186 info_hash: original_info_hash.to_string(),
187 })
188 }
189
190 async fn validate_and_build_metadata(&self, add_torrent_req: &AddTorrentRequest) -> Result<Metadata, ServiceError> {
191 if add_torrent_req.category_name.is_empty() {
192 return Err(ServiceError::MissingMandatoryMetadataFields);
193 }
194
195 let category = self
196 .category_repository
197 .get_by_name(&add_torrent_req.category_name)
198 .await
199 .map_err(|_| ServiceError::InvalidCategory)?;
200
201 let metadata = Metadata::new(
202 &add_torrent_req.title,
203 &add_torrent_req.description,
204 category.category_id,
205 &add_torrent_req.tags,
206 )?;
207
208 Ok(metadata)
209 }
210
211 async fn canonical_info_hash_group_checks(
212 &self,
213 original_info_hash: &InfoHash,
214 canonical_info_hash: &InfoHash,
215 ) -> Result<(), ServiceError> {
216 let original_info_hashes = self
217 .torrent_info_hash_repository
218 .get_canonical_info_hash_group(canonical_info_hash)
219 .await?;
220
221 if !original_info_hashes.is_empty() {
222 debug!("Canonical infohash found: {:?}", canonical_info_hash.to_hex_string());
226
227 if let Some(original_info_hash) = original_info_hashes.find(original_info_hash) {
228 debug!("Original infohash found: {:?}", original_info_hash.to_hex_string());
230
231 return Err(ServiceError::OriginalInfoHashAlreadyExists);
232 }
233
234 debug!("Original infohash not found: {:?}", original_info_hash.to_hex_string());
236
237 self.torrent_info_hash_repository
239 .add_info_hash_to_canonical_info_hash_group(original_info_hash, canonical_info_hash)
240 .await?;
241 return Err(ServiceError::CanonicalInfoHashAlreadyExists);
242 }
243
244 Ok(())
246 }
247
248 async fn customize_announcement_info_for(&self, torrent: &mut Torrent) {
249 let settings = self.configuration.settings.read().await;
250 let tracker_url = settings.tracker.url.clone();
251 torrent.set_announce_to(&tracker_url);
252 torrent.reset_announce_list_if_private();
253 }
254
255 async fn import_torrent_statistics_from_tracker(&self, torrent_id: TorrentId, canonical_info_hash: &InfoHash) {
256 drop(
257 self.tracker_statistics_importer
258 .import_torrent_statistics(torrent_id, &canonical_info_hash.to_hex_string())
259 .await,
260 );
261 }
262
263 pub async fn get_torrent(&self, info_hash: &InfoHash, maybe_user_id: Option<UserId>) -> Result<Torrent, ServiceError> {
270 self.authorization_service
271 .authorize(ACTION::GetTorrent, maybe_user_id)
272 .await?;
273
274 let mut torrent = self.torrent_repository.get_by_info_hash(info_hash).await?;
275
276 let tracker_url = self.get_tracker_url().await;
277 let tracker_is_private = self.tracker_is_private().await;
278
279 if !tracker_is_private {
283 torrent.include_url_as_main_tracker(&tracker_url);
284 } else if let Some(authenticated_user_id) = maybe_user_id {
285 let personal_announce_url = self.tracker_service.get_personal_announce_url(authenticated_user_id).await?;
286 torrent.include_url_as_main_tracker(&personal_announce_url);
287 } else {
288 torrent.include_url_as_main_tracker(&tracker_url);
289 }
290
291 Ok(torrent)
292 }
293
294 pub async fn delete_torrent(
305 &self,
306 info_hash: &InfoHash,
307 maybe_user_id: Option<UserId>,
308 ) -> Result<DeletedTorrentResponse, ServiceError> {
309 self.authorization_service
310 .authorize(ACTION::DeleteTorrent, maybe_user_id)
311 .await?;
312
313 let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?;
314
315 self.torrent_repository.delete(&torrent_listing.torrent_id).await?;
316
317 let _unused = self
320 .tracker_service
321 .remove_info_hash_from_whitelist(info_hash.to_string())
322 .await;
323
324 Ok(DeletedTorrentResponse {
325 torrent_id: torrent_listing.torrent_id,
326 info_hash: torrent_listing.info_hash,
327 })
328 }
329
330 pub async fn get_torrent_info(
342 &self,
343 info_hash: &InfoHash,
344 maybe_user_id: Option<UserId>,
345 ) -> Result<TorrentResponse, ServiceError> {
346 self.authorization_service
347 .authorize(ACTION::GetTorrentInfo, maybe_user_id)
348 .await?;
349
350 let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?;
351
352 let torrent_response = self
353 .build_full_torrent_response(torrent_listing, info_hash, maybe_user_id)
354 .await?;
355
356 Ok(torrent_response)
357 }
358
359 pub async fn generate_torrent_info_listing(
365 &self,
366 request: &ListingRequest,
367 maybe_user_id: Option<UserId>,
368 ) -> Result<TorrentsResponse, ServiceError> {
369 self.authorization_service
370 .authorize(ACTION::GenerateTorrentInfoListing, maybe_user_id)
371 .await?;
372
373 let torrent_listing_specification = self.listing_specification_from_user_request(request).await;
374
375 let torrents_response = self
376 .torrent_listing_generator
377 .generate_listing(&torrent_listing_specification)
378 .await?;
379
380 Ok(torrents_response)
381 }
382
383 async fn listing_specification_from_user_request(&self, request: &ListingRequest) -> ListingSpecification {
386 let settings = self.configuration.settings.read().await;
387 let default_torrent_page_size = settings.api.default_torrent_page_size;
388 let max_torrent_page_size = settings.api.max_torrent_page_size;
389 drop(settings);
390
391 let sort = request.sort.unwrap_or(Sorting::UploadedDesc);
392 let page = request.page.unwrap_or(0);
393 let page_size = request.page_size.unwrap_or(default_torrent_page_size);
394
395 let page_size = if page_size > max_torrent_page_size {
397 max_torrent_page_size
398 } else {
399 page_size
400 };
401
402 let offset = u64::from(page * u32::from(page_size));
403
404 let categories = request.categories.as_csv::<String>().unwrap_or(None);
405
406 let tags = request.tags.as_csv::<String>().unwrap_or(None);
407
408 ListingSpecification {
409 search: request.search.clone(),
410 categories,
411 tags,
412 sort,
413 offset,
414 page_size,
415 }
416 }
417
418 pub async fn update_torrent_info(
429 &self,
430 info_hash: &InfoHash,
431 title: &Option<String>,
432 description: &Option<String>,
433 category_id: &Option<CategoryId>,
434 tags: &Option<Vec<TagId>>,
435 user_id: &UserId,
436 ) -> Result<TorrentResponse, ServiceError> {
437 let updater = self.user_repository.get_compact(user_id).await?;
438
439 let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?;
440
441 if !(torrent_listing.uploader == updater.username || updater.administrator) {
444 return Err(ServiceError::UnauthorizedAction);
445 }
446
447 self.torrent_info_repository
448 .update(&torrent_listing.torrent_id, title, description, category_id, tags)
449 .await?;
450
451 let torrent_listing = self
452 .torrent_listing_generator
453 .one_torrent_by_torrent_id(&torrent_listing.torrent_id)
454 .await?;
455
456 let torrent_response = self.build_short_torrent_response(torrent_listing, info_hash).await?;
457
458 Ok(torrent_response)
459 }
460
461 async fn get_tracker_url(&self) -> Url {
462 let settings = self.configuration.settings.read().await;
463 settings.tracker.url.clone()
464 }
465
466 async fn tracker_is_private(&self) -> bool {
467 let settings = self.configuration.settings.read().await;
468 settings.tracker.private
469 }
470
471 async fn build_short_torrent_response(
472 &self,
473 torrent_listing: TorrentListing,
474 info_hash: &InfoHash,
475 ) -> Result<TorrentResponse, ServiceError> {
476 let category = match torrent_listing.category_id {
477 Some(category_id) => Some(self.category_repository.get_by_id(&category_id).await?),
478 None => None,
479 };
480
481 let canonical_info_hash_group = self
482 .torrent_info_hash_repository
483 .get_canonical_info_hash_group(info_hash)
484 .await?;
485
486 Ok(TorrentResponse::from_listing(
487 torrent_listing,
488 category,
489 &canonical_info_hash_group,
490 ))
491 }
492
493 async fn build_full_torrent_response(
494 &self,
495 torrent_listing: TorrentListing,
496 info_hash: &InfoHash,
497 maybe_user_id: Option<UserId>,
498 ) -> Result<TorrentResponse, ServiceError> {
499 let torrent_id: i64 = torrent_listing.torrent_id;
500
501 let mut torrent_response = self.build_short_torrent_response(torrent_listing, info_hash).await?;
502
503 torrent_response.files = self.torrent_file_repository.get_by_torrent_id(&torrent_id).await?;
506
507 if torrent_response.files.len() == 1 {
508 let torrent_info = self.torrent_info_repository.get_by_info_hash(info_hash).await?;
509
510 torrent_response
511 .files
512 .iter_mut()
513 .for_each(|v| v.path = vec![torrent_info.name.to_string()]);
514 }
515
516 torrent_response.trackers = self.torrent_announce_url_repository.get_by_torrent_id(&torrent_id).await?;
523
524 let tracker_url = self.get_tracker_url().await;
525
526 if self.tracker_is_private().await {
527 match maybe_user_id {
529 Some(user_id) => {
530 let personal_announce_url = self.tracker_service.get_personal_announce_url(user_id).await?;
531
532 torrent_response.include_url_as_main_tracker(&personal_announce_url);
533 }
534 None => {
535 torrent_response.include_url_as_main_tracker(&tracker_url);
536 }
537 }
538 } else {
539 torrent_response.include_url_as_main_tracker(&tracker_url);
540 }
541
542 let mut magnet = format!(
546 "magnet:?xt=urn:btih:{}&dn={}",
547 torrent_response.info_hash,
548 urlencoding::encode(&torrent_response.title)
549 );
550
551 for tracker in &torrent_response.trackers {
553 magnet.push_str(&format!("&tr={}", urlencoding::encode(tracker)));
554 }
555
556 torrent_response.magnet_link = magnet;
557
558 if let Ok(torrent_info) = self
560 .tracker_statistics_importer
561 .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash)
562 .await
563 {
564 torrent_response.seeders = torrent_info.seeders;
565 torrent_response.leechers = torrent_info.leechers;
566 }
567
568 torrent_response.tags = self.torrent_tag_repository.get_tags_for_torrent(&torrent_id).await?;
569
570 Ok(torrent_response)
571 }
572
573 pub async fn get_canonical_info_hash(
579 &self,
580 info_hash: &InfoHash,
581 maybe_user_id: Option<UserId>,
582 ) -> Result<Option<InfoHash>, ServiceError> {
583 self.authorization_service
584 .authorize(ACTION::GetCanonicalInfoHash, maybe_user_id)
585 .await?;
586
587 self.torrent_info_hash_repository
588 .find_canonical_info_hash_for(info_hash)
589 .await
590 .map_err(|_| ServiceError::DatabaseError)
591 }
592}
593
594pub struct DbTorrentRepository {
595 database: Arc<Box<dyn Database>>,
596}
597
598impl DbTorrentRepository {
599 #[must_use]
600 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
601 Self { database }
602 }
603
604 pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result<Torrent, Error> {
610 self.database.get_torrent_from_info_hash(info_hash).await
611 }
612
613 pub async fn add(
619 &self,
620 original_info_hash: &InfoHash,
621 torrent: &Torrent,
622 metadata: &Metadata,
623 user_id: UserId,
624 ) -> Result<TorrentId, Error> {
625 self.database
626 .insert_torrent_and_get_id(original_info_hash, torrent, user_id, metadata)
627 .await
628 }
629
630 pub async fn delete(&self, torrent_id: &TorrentId) -> Result<(), Error> {
636 self.database.delete_torrent(*torrent_id).await
637 }
638}
639
640#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
641pub struct DbTorrentInfoHash {
642 pub info_hash: String,
643 pub canonical_info_hash: String,
644 pub original_is_known: bool,
645}
646
647#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
656pub struct CanonicalInfoHashGroup {
657 pub canonical_info_hash: InfoHash,
658 pub original_info_hashes: Vec<InfoHash>,
660}
661pub struct DbCanonicalInfoHashGroupRepository {
662 database: Arc<Box<dyn Database>>,
663}
664
665impl CanonicalInfoHashGroup {
666 #[must_use]
667 pub fn is_empty(&self) -> bool {
668 self.original_info_hashes.is_empty()
669 }
670
671 #[must_use]
672 pub fn find(&self, original_info_hash: &InfoHash) -> Option<&InfoHash> {
673 self.original_info_hashes.iter().find(|&hash| *hash == *original_info_hash)
674 }
675}
676
677impl DbCanonicalInfoHashGroupRepository {
678 #[must_use]
679 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
680 Self { database }
681 }
682
683 pub async fn get_canonical_info_hash_group(&self, info_hash: &InfoHash) -> Result<CanonicalInfoHashGroup, Error> {
693 self.database.get_torrent_canonical_info_hash_group(info_hash).await
694 }
695
696 pub async fn find_canonical_info_hash_for(&self, info_hash: &InfoHash) -> Result<Option<InfoHash>, Error> {
705 self.database.find_canonical_info_hash_for(info_hash).await
706 }
707
708 pub async fn add_info_hash_to_canonical_info_hash_group(
717 &self,
718 original_info_hash: &InfoHash,
719 canonical_info_hash: &InfoHash,
720 ) -> Result<(), Error> {
721 self.database
722 .add_info_hash_to_canonical_info_hash_group(original_info_hash, canonical_info_hash)
723 .await
724 }
725}
726
727pub struct DbTorrentInfoRepository {
728 database: Arc<Box<dyn Database>>,
729}
730
731impl DbTorrentInfoRepository {
732 #[must_use]
733 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
734 Self { database }
735 }
736
737 pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result<DbTorrent, Error> {
743 self.database.get_torrent_info_from_info_hash(info_hash).await
744 }
745
746 pub async fn update(
752 &self,
753 torrent_id: &TorrentId,
754 opt_title: &Option<String>,
755 opt_description: &Option<String>,
756 opt_category_id: &Option<CategoryId>,
757 opt_tags: &Option<Vec<TagId>>,
758 ) -> Result<(), Error> {
759 if let Some(title) = &opt_title {
760 self.database.update_torrent_title(*torrent_id, title).await?;
761 }
762
763 if let Some(description) = &opt_description {
764 self.database.update_torrent_description(*torrent_id, description).await?;
765 }
766
767 if let Some(category_id) = &opt_category_id {
768 self.database.update_torrent_category(*torrent_id, *category_id).await?;
769 }
770
771 if let Some(tags) = opt_tags {
772 let mut current_tags: Vec<TagId> = self
773 .database
774 .get_tags_for_torrent_id(*torrent_id)
775 .await?
776 .iter()
777 .map(|tag| tag.tag_id)
778 .collect();
779
780 let mut new_tags = tags.clone();
781
782 current_tags.sort_unstable();
783 new_tags.sort_unstable();
784
785 if new_tags != current_tags {
786 self.database.delete_all_torrent_tag_links(*torrent_id).await?;
787 self.database.add_torrent_tag_links(*torrent_id, tags).await?;
788 }
789 }
790
791 Ok(())
792 }
793}
794
795pub struct DbTorrentFileRepository {
796 database: Arc<Box<dyn Database>>,
797}
798
799impl DbTorrentFileRepository {
800 #[must_use]
801 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
802 Self { database }
803 }
804
805 pub async fn get_by_torrent_id(&self, torrent_id: &TorrentId) -> Result<Vec<TorrentFile>, Error> {
811 self.database.get_torrent_files_from_id(*torrent_id).await
812 }
813}
814
815pub struct DbTorrentAnnounceUrlRepository {
816 database: Arc<Box<dyn Database>>,
817}
818
819impl DbTorrentAnnounceUrlRepository {
820 #[must_use]
821 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
822 Self { database }
823 }
824
825 pub async fn get_by_torrent_id(&self, torrent_id: &TorrentId) -> Result<Vec<String>, Error> {
831 self.database
832 .get_torrent_announce_urls_from_id(*torrent_id)
833 .await
834 .map(|v| v.into_iter().flatten().collect())
835 }
836}
837
838pub struct DbTorrentTagRepository {
839 database: Arc<Box<dyn Database>>,
840}
841
842impl DbTorrentTagRepository {
843 #[must_use]
844 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
845 Self { database }
846 }
847
848 pub async fn link_torrent_to_tag(&self, torrent_id: &TorrentId, tag_id: &TagId) -> Result<(), Error> {
854 self.database.add_torrent_tag_link(*torrent_id, *tag_id).await
855 }
856
857 pub async fn link_torrent_to_tags(&self, torrent_id: &TorrentId, tag_ids: &[TagId]) -> Result<(), Error> {
863 self.database.add_torrent_tag_links(*torrent_id, tag_ids).await
864 }
865
866 pub async fn get_tags_for_torrent(&self, torrent_id: &TorrentId) -> Result<Vec<TorrentTag>, Error> {
872 self.database.get_tags_for_torrent_id(*torrent_id).await
873 }
874
875 pub async fn unlink_torrent_from_tag(&self, torrent_id: &TorrentId, tag_id: &TagId) -> Result<(), Error> {
881 self.database.delete_torrent_tag_link(*torrent_id, *tag_id).await
882 }
883
884 pub async fn unlink_all_tags_for_torrent(&self, torrent_id: &TorrentId) -> Result<(), Error> {
890 self.database.delete_all_torrent_tag_links(*torrent_id).await
891 }
892}
893
894pub struct DbTorrentListingGenerator {
895 database: Arc<Box<dyn Database>>,
896}
897
898impl DbTorrentListingGenerator {
899 #[must_use]
900 pub fn new(database: Arc<Box<dyn Database>>) -> Self {
901 Self { database }
902 }
903
904 pub async fn one_torrent_by_info_hash(&self, info_hash: &InfoHash) -> Result<TorrentListing, Error> {
910 self.database.get_torrent_listing_from_info_hash(info_hash).await
911 }
912
913 pub async fn one_torrent_by_torrent_id(&self, torrent_id: &TorrentId) -> Result<TorrentListing, Error> {
919 self.database.get_torrent_listing_from_id(*torrent_id).await
920 }
921
922 pub async fn generate_listing(&self, specification: &ListingSpecification) -> Result<TorrentsResponse, Error> {
928 self.database
929 .get_torrents_search_sorted_paginated(
930 &specification.search,
931 &specification.categories,
932 &specification.tags,
933 &specification.sort,
934 specification.offset,
935 specification.page_size,
936 )
937 .await
938 }
939}