torrust_index/services/
torrent.rs

1//! Torrent service.
2use 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/// User request to generate a torrent listing.
56#[derive(Debug, Deserialize)]
57pub struct ListingRequest {
58    pub page_size: Option<u8>,
59    pub page: Option<u32>,
60    pub sort: Option<Sorting>,
61    /// Expects comma separated string, eg: "?categories=movie,other,app"
62    pub categories: Option<String>,
63    /// Expects comma separated string, eg: "?tags=Linux,Ubuntu"
64    pub tags: Option<String>,
65    pub search: Option<String>,
66}
67
68/// Internal specification for torrent listings.
69#[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    /// Adds a torrent to the index.
115    ///
116    /// # Errors
117    ///
118    /// This function will return an error if:
119    ///
120    /// * Unable to get the user from the database.
121    /// * Unable to get torrent request from payload.
122    /// * Unable to get the category from the database.
123    /// * Unable to insert the torrent into the database.
124    /// * Unable to add the torrent to the whitelist.
125    /// * Torrent title is too short.
126    ///
127    /// # Panics
128    ///
129    /// This function will panic if:
130    ///
131    /// * Unable to parse the torrent info-hash.
132    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        // Synchronous secondary tasks
160
161        // code-review: consider moving this to a background task
162        self.import_torrent_statistics_from_tracker(torrent_id, &torrent.canonical_info_hash())
163            .await;
164
165        // We always whitelist the torrent on the tracker because
166        // even if the tracker mode is `public` it could be changed to `private`
167        // later on.
168        //
169        // code-review: maybe we should consider adding a new feature to
170        // whitelist  all torrents from the admin panel if that change happens.
171        if let Err(e) = self
172            .tracker_service
173            .whitelist_info_hash(torrent.canonical_info_hash_hex())
174            .await
175        {
176            // If the torrent can't be whitelisted somehow, remove the torrent from database
177            drop(self.torrent_repository.delete(&torrent_id).await);
178            return Err(e.into());
179        }
180
181        // Build response
182
183        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            // A previous torrent with the same canonical infohash has been uploaded before
223
224            // Torrent with the same canonical infohash was already uploaded
225            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                // The exact original infohash was already uploaded
229                debug!("Original infohash found: {:?}", original_info_hash.to_hex_string());
230
231                return Err(ServiceError::OriginalInfoHashAlreadyExists);
232            }
233
234            // A new original infohash is being uploaded with a canonical infohash that already exists.
235            debug!("Original infohash not found: {:?}", original_info_hash.to_hex_string());
236
237            // Add the new associated original infohash to the canonical one.
238            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        // No other torrent with the same canonical infohash has been uploaded before
245        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    /// Gets a torrent from the Index.
264    ///
265    /// # Errors
266    ///
267    /// This function will return an error if unable to get the torrent from the
268    /// database.
269    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        // code-review: should we remove all tracker URLs in the `announce_list`
280        // when the tracker is private?
281
282        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    /// Delete a Torrent from the Index
295    ///
296    /// # Errors
297    ///
298    /// This function will return an error if:
299    ///
300    /// * Unable to get the user who is deleting the torrent (logged-in user).
301    /// * The user does not have permission to delete the torrent.
302    /// * Unable to get the torrent listing from it's ID.
303    /// * Unable to delete the torrent from the database.
304    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        // Remove info-hash from tracker whitelist
318        // todo: handle the error when the tracker is offline or not well configured.
319        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    /// Get torrent info from the Index
331    ///
332    /// # Errors
333    ///
334    /// This function will return an error if:
335    /// * Unable to get torrent ID.
336    /// * Unable to get torrent listing from id.
337    /// * Unable to get torrent category from id.
338    /// * Unable to get torrent files from id.
339    /// * Unable to get torrent info from id.
340    /// * Unable to get torrent announce url(s) from id.
341    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    /// It returns a list of torrents matching the search criteria.
360    ///
361    /// # Errors
362    ///
363    /// Returns a `ServiceError::DatabaseError` if the database query fails.
364    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    /// It converts the user listing request into an internal listing
384    /// specification.
385    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        // Guard that page size does not exceed the maximum
396        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    /// Update the torrent info on the Index.
419    ///
420    /// # Errors
421    ///
422    /// This function will return an error if:
423    ///
424    /// * Unable to get the user.
425    /// * Unable to get listing from id.
426    /// * Unable to update the torrent tile or description.
427    /// * User does not have the permissions to update the torrent.
428    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        // Check if user is owner or administrator
442        // todo: move this to an authorization service.
443        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        // Add files
504
505        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        // Add trackers
517
518        // code-review: duplicate logic. We have to check the same in the
519        // download torrent file endpoint. Here he have only one list of tracker
520        // like the `announce_list` in the torrent file.
521
522        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            // Add main tracker URL
528            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        // Add magnet link
543
544        // todo: extract a struct or function to build the magnet links
545        let mut magnet = format!(
546            "magnet:?xt=urn:btih:{}&dn={}",
547            torrent_response.info_hash,
548            urlencoding::encode(&torrent_response.title)
549        );
550
551        // Add trackers from torrent file to magnet link
552        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        // Get realtime seeders and leechers
559        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    /// Returns the canonical info-hash.
574    ///
575    /// # Errors
576    ///
577    /// Returns an error if the user is not authorized or if there is a problem with the database.
578    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    /// It finds the torrent by info-hash.
605    ///
606    /// # Errors
607    ///
608    /// This function will return an error there is a database error.
609    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    /// Inserts the entire torrent in the database.
614    ///
615    /// # Errors
616    ///
617    /// This function will return an error there is a database error.
618    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    /// Deletes the entire torrent in the database.
631    ///
632    /// # Errors
633    ///
634    /// This function will return an error there is a database error.
635    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/// All the infohashes associated to a canonical one.
648///
649/// When you upload a torrent the info-hash migth change because the Index
650/// remove the non-standard fields in the `info` dictionary. That makes the
651/// infohash change. The canonical infohash is the resulting infohash.
652/// This function returns the original infohashes of a canonical infohash.
653///
654/// The relationship is 1 canonical infohash -> N original infohashes.
655#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
656pub struct CanonicalInfoHashGroup {
657    pub canonical_info_hash: InfoHash,
658    /// The list of original infohashes associated to the canonical one.
659    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    /// It returns all the infohashes associated to the canonical one.
684    ///
685    /// # Errors
686    ///
687    /// This function will return an error there is a database error.
688    ///
689    /// # Errors
690    ///
691    /// Returns an error is there was a problem with the database.
692    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    /// It returns the list of all infohashes producing the same canonical
697    /// infohash.
698    ///
699    /// If the original infohash was unknown, it returns the canonical infohash.
700    ///
701    /// # Errors
702    ///
703    /// Returns an error is there was a problem with the database.
704    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    /// It returns the list of all infohashes producing the same canonical
709    /// infohash.
710    ///
711    /// If the original infohash was unknown, it returns the canonical infohash.
712    ///
713    /// # Errors
714    ///
715    /// Returns an error is there was a problem with the database.
716    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    /// It finds the torrent info by info-hash.
738    ///
739    /// # Errors
740    ///
741    /// This function will return an error there is a database error.
742    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    /// It updates the torrent title or/and description by torrent ID.
747    ///
748    /// # Errors
749    ///
750    /// This function will return an error there is a database error.
751    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    /// It finds the torrent files by torrent id
806    ///
807    /// # Errors
808    ///
809    /// It returns an error if there is a database error.
810    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    /// It finds the announce URLs by torrent id
826    ///
827    /// # Errors
828    ///
829    /// It returns an error if there is a database error.
830    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    /// It adds a new torrent tag link.
849    ///
850    /// # Errors
851    ///
852    /// It returns an error if there is a database error.
853    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    /// It adds multiple torrent tag links at once.
858    ///
859    /// # Errors
860    ///
861    /// It returns an error if there is a database error.
862    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    /// It returns all the tags linked to a certain torrent ID.
867    ///
868    /// # Errors
869    ///
870    /// It returns an error if there is a database error.
871    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    /// It removes a torrent tag link.
876    ///
877    /// # Errors
878    ///
879    /// It returns an error if there is a database error.
880    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    /// It removes all tags for a certain torrent.
885    ///
886    /// # Errors
887    ///
888    /// It returns an error if there is a database error.
889    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    /// It finds the torrent listing by info-hash
905    ///
906    /// # Errors
907    ///
908    /// It returns an error if there is a database error.
909    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    /// It finds the torrent listing by torrent ID.
914    ///
915    /// # Errors
916    ///
917    /// It returns an error if there is a database error.
918    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    /// It finds the torrent listing by torrent ID.
923    ///
924    /// # Errors
925    ///
926    /// It returns an error if there is a database error.
927    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}