Coverage for seedboxsync/core/database/models/torrent.py: 61%
69 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""Peewee DAO model for Torrent."""
9from collections.abc import Iterable
10import datetime
11from os import PathLike
12from typing import Any, cast
13from urllib.parse import urlparse
14from peewee import AutoField, BooleanField, DateTimeField, IntegerField, TextField, fn
15import tldextract
16from seedboxsync.core import utils
17from seedboxsync.core.database.models import SeedboxSyncModel
20class Torrent(SeedboxSyncModel):
21 """
22 Data Access Object (DAO) representing a torrent.
24 This model stores metadata for a torrent file, including tracker info,
25 source provenance, file counts, payload size, privacy status, and processing timestamp.
27 Attributes:
28 id (int): Auto-incremented primary key.
29 name (str): Name of the torrent.
30 announce (str): Tracker announce URL.
31 announcer (str): Tracker announce domain name.
32 source (str): Source or provenance tag of the torrent file.
33 total_files (int): Total number of files contained in the torrent.
34 total_size (int): Total size of all files in the torrent in bytes.
35 private (bool): Flag indicating if the torrent is marked as private.
36 sent (datetime): Timestamp indicating when the torrent was sent or created.
37 """
39 id = AutoField(help_text="Unique identifier of the torrent")
40 name = TextField(help_text="Name of the torrent")
41 announce = TextField(null=True, help_text="Tracker announce URL of the torrent")
42 announcer = TextField(null=True, help_text="Tracker announce domain of the torrent")
43 source = TextField(null=True, help_text="Source or provenance of the torrent file")
44 total_files = IntegerField(null=True, help_text="Total number of files contained in the torrent")
45 total_size = IntegerField(null=True, help_text="Total size of all files in bytes")
46 private = BooleanField(null=False, default=False, help_text="Flag indicating if the torrent is private")
47 sent = DateTimeField(default=datetime.datetime.now, help_text="Timestamp when the torrent was sent")
49 def save(self, force_insert: bool = False, only: Any | None = None) -> int:
50 """Save the model instance, automatically updating the announcer domain."""
51 self.update_announcer()
52 return super().save(force_insert=force_insert, only=only)
54 def set_from_file(self, torrent_file: str | PathLike[str]) -> bool:
55 """
56 Populate the model from a torrent file.
58 Args:
59 torrent_file: Path to the torrent file.
61 Returns:
62 True if the torrent is valid and minimal informations was successfully extracted,
63 otherwise False.
64 """
65 torrent_info = utils.get_torrent_infos(torrent_file)
66 # Torrent is not valid
67 if not isinstance(torrent_info, dict):
68 return False
70 # Get minimal information
71 self.announce = torrent_info.get("announce") or None
73 # Get extended informations
74 info = torrent_info.get("info")
75 if not isinstance(info, dict):
76 return True
78 self.source = info.get("source") or None
79 self.private = info.get("private", False)
81 self.total_files = None
82 self.total_size = None
83 files = info.get("files")
84 if isinstance(files, list):
85 # Multi-file torrent.
86 valid_files = [file_info for file_info in files if isinstance(file_info, dict) and isinstance(file_info.get("length"), int)]
87 self.total_files = len(valid_files)
88 self.total_size = sum(file_info["length"] for file_info in valid_files)
89 else:
90 # Single-file torrent.
91 length = info.get("length")
92 self.total_files = 1 if isinstance(length, int) else None
93 self.total_size = length if isinstance(length, int) else None
95 return True
97 def update_announcer(self) -> None:
98 """
99 Extract and populate the announcer domain from the announce URL.
101 Parses ``announce`` URL and extracts the registrable domain (without subdomains).
102 """
103 self.announcer = None # reset on update
104 if not self.announce:
105 return
107 hostname = urlparse(self.announce).hostname
108 if not hostname:
109 return
111 # Use tldextract
112 extracted = tldextract.extract(hostname)
113 if not extracted.domain or not extracted.suffix:
114 self.announcer = hostname
115 return
117 # Set announcer
118 self.announcer = f"{extracted.domain}.{extracted.suffix}"
120 # Source fallback
121 if self.source == "" or self.source is None:
122 self.source = self.announcer
124 @classmethod
125 def get_stats_by_source(cls, start_date: datetime.date | None = None, end_date: datetime.date | None = None) -> list[dict[str, Any]]:
126 """
127 Get the total count and total size of torrents grouped by source.
129 Args:
130 start_date (datetime.date | None): Optional start date filter.
131 end_date (datetime.date | None): Optional end date filter.
133 Returns:
134 list[dict[str, Any]]: A list of dicts containing mime_type, total count, and total_size.
135 """
136 # Build "where" expression
137 conditions = []
138 if start_date:
139 conditions.append(cls.sent >= start_date)
140 if end_date:
141 conditions.append(cls.sent <= end_date)
143 query = (
144 cls.select(
145 cls.source,
146 fn.COUNT(cls.id).alias("total"),
147 fn.SUM(cls.total_size).alias("total_size"),
148 fn.humanize(fn.SUM(cls.total_size)).alias("human_total_size"),
149 )
150 .group_by(cls.source)
151 .order_by(fn.SUM(cls.total_size).desc())
152 )
154 # if "where" expression
155 if conditions:
156 query = query.where(*conditions)
157 data = query.dicts()
159 return list(cast(Iterable[dict[str, Any]], data))