Coverage for seedboxsync/front/apis/downloads_stats.py: 86%
86 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 07:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 07:37 +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"""SeedboxSync api error module."""
9from datetime import date
10from typing import Any
11from flask_restx import Namespace, fields
12from peewee import fn
13from seedboxsync.core import utils
14from seedboxsync.core.database.models import Download, typed_peewee_dicts
15from seedboxsync.front.apis import Resource, parser_period
16from seedboxsync.front.cache import cache
17from seedboxsync.front.login_manager import login_required
19api = Namespace("downloads_stats", path="/downloads/stats", description="Stats related to download")
22# ==========================
23# Models
24# ==========================
25stats_month_model = api.model(
26 "DownloadStatsByMonth",
27 {
28 "files": fields.Integer(
29 required=True,
30 description="Number of files downloaded in the month",
31 example=135,
32 ),
33 "month": fields.String(
34 required=True,
35 description="Year and month of the statistics (format: yyyy-mm)",
36 pattern=r"^\d{4}-(0[1-9]|1[0-2])$",
37 example="2025-08",
38 ),
39 "total_size": fields.String(
40 required=True,
41 description="Total size of files downloaded",
42 example="427.8GiB",
43 ),
44 },
45)
46stats_month_envelope = Resource.build_envelope_model(api, "DownloadStatsByMonthList", nested_model=stats_month_model)
48stats_year_model = api.model(
49 "DownloadStatsByYear",
50 {
51 "files": fields.Integer(
52 required=True,
53 description="Number of files downloaded in the year",
54 example=4989,
55 ),
56 "year": fields.String(
57 required=True,
58 description="Year of the statistics (format: yyyy)",
59 pattern=r"^\d{4}$",
60 example="2018",
61 ),
62 "total_size": fields.String(
63 required=True,
64 description="Total size of files downloaded",
65 example="1476.5GiB",
66 ),
67 },
68)
69stats_year_envelope = Resource.build_envelope_model(api, "DownloadStatsByYearList", nested_model=stats_year_model)
71stats_mimetype_model = api.model(
72 "DownloadStatsByMimeType",
73 {
74 "mime_type": fields.String(
75 required=True,
76 description="MIME type identifier detected for the files",
77 pattern=r"^[a-zA-Z0-9!#$&^_\-\+\.]+/[a-zA-Z0-9!#$&^_\-\+\.]+$",
78 example="video/x-matroska",
79 ),
80 "total": fields.Integer(
81 required=True,
82 description="Number or size of files downloaded for this MIME type",
83 example=4989,
84 ),
85 "total_size": fields.Integer(
86 required=True,
87 description="Size of files downloaded for this MIME type",
88 example=21678643250867,
89 ),
90 "human_total_size": fields.String(
91 required=True,
92 description="Total size of files downloaded with related humanization",
93 example="19.7 Tio",
94 ),
95 },
96)
97stats_mimetype_envelope = Resource.build_envelope_model(api, "DownloadStatsByMimeTypeList", nested_model=stats_mimetype_model)
99stats_item_model = api.model(
100 "DownloadStatsItem",
101 {
102 "total": fields.Integer(
103 description="Number of downloads",
104 example=12,
105 ),
106 "size": fields.Integer(
107 description="Total size of downloads in bytes",
108 example=123456789,
109 ),
110 "human_size": fields.String(
111 description="Human-readable total size of downloads",
112 example="117.74 MB",
113 ),
114 "trend_total": fields.String(
115 description="Trends vs. prior period for number of downloads",
116 example="+10.0",
117 ),
118 "trend_size": fields.String(
119 description="Trends vs. prior period for size of downloads",
120 example="+15.2",
121 ),
122 },
123)
124stats_model = api.model(
125 "DownloadStats",
126 {
127 "day": fields.Nested(
128 stats_item_model,
129 description="Statistics for the current day",
130 ),
131 "week": fields.Nested(
132 stats_item_model,
133 description="Statistics for the current calendar week",
134 ),
135 "month": fields.Nested(
136 stats_item_model,
137 description="Statistics for the current month",
138 ),
139 "year": fields.Nested(
140 stats_item_model,
141 description="Statistics for the current year",
142 ),
143 "last24h": fields.Nested(
144 stats_item_model,
145 description="Statistics for the last 24 hours",
146 ),
147 "last7d": fields.Nested(
148 stats_item_model,
149 description="Statistics for the last 7 days",
150 ),
151 "last30d": fields.Nested(
152 stats_item_model,
153 description="Statistics for the last 30 days",
154 ),
155 },
156)
157stats_envelope = Resource.build_envelope_model(api, "DownloadStats", nested_model=stats_model, as_list=False)
160# ==========================
161# Endpoints
162# ==========================
163@api.route("")
164class DownloadsStats(Resource):
165 """Resource endpoint to retrieve download statistics."""
167 @api.doc("stats_downloads") # type: ignore[untyped-decorator]
168 @api.marshal_with(stats_envelope, code=200, description="Download statistics") # type: ignore[untyped-decorator]
169 @login_required # type: ignore[untyped-decorator]
170 def get(self) -> dict[str, Any]:
171 """
172 Fetch overall download statistics.
174 Retrieves aggregated download metrics and wraps them within
175 a standard API response envelope[cite: 1, 2].
177 Returns:
178 dict[str, Any]: Formatted envelope response containing download statistics[cite: 1, 2].
179 """
180 stats = _get_stats()
181 return self.build_envelope(stats, data_total=len(stats), type="DownloadStats")
184@api.route("/month")
185class DownloadsStatsByMonth(Resource):
186 """Endpoint to retrieve monthly download statistics."""
188 @api.doc("stats_downloads_by_month") # type: ignore[untyped-decorator]
189 @api.marshal_with(stats_month_envelope, code=200, description="Download statistics aggregated by month") # type: ignore[untyped-decorator]
190 @api.expect(parser_period) # type: ignore[untyped-decorator]
191 @login_required # type: ignore[untyped-decorator]
192 def get(self) -> dict[str, Any]:
193 """
194 Return download statistics grouped by month.
196 Returns the number of files downloaded and total size per month.
197 """
198 args = parser_period.parse_args()
199 start_date = args.get("start_date")
200 end_date = args.get("end_date")
202 stats = stats_by_period("month", start_date, end_date)
204 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByMonthList")
207@api.route("/year")
208class DownloadsStatsByYear(Resource):
209 """Endpoint to retrieve yearly download statistics."""
211 @api.doc("stats_downloads_by_year") # type: ignore[untyped-decorator]
212 @api.marshal_with(stats_year_envelope, code=200, description="Download statistics aggregated by year") # type: ignore[untyped-decorator]
213 @login_required # type: ignore[untyped-decorator]
214 def get(self) -> dict[str, Any]:
215 """
216 Return download statistics grouped by year.
218 Returns the number of files downloaded and total size per year.
219 """
220 stats = stats_by_period("year")
222 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByYearList")
225@api.route("/mimetype")
226class DownloadsStatsByMimeType(Resource):
227 """Resource endpoint to retrieve download MIME type statistics."""
229 @api.doc("stats_downloads_by_mimetype") # type: ignore[untyped-decorator]
230 @api.marshal_with(stats_mimetype_envelope, code=200, description="Download statistics aggregated by mimetype") # type: ignore[untyped-decorator]
231 @api.expect(parser_period) # type: ignore[untyped-decorator]
232 @login_required # type: ignore[untyped-decorator]
233 def get(self) -> dict[str, Any]:
234 """
235 Retrieve download statistics grouped by MIME type.
237 Fetches aggregated file counts and total sizes per MIME type from the cache
238 or database, then wraps the dataset into a standard API response envelope.
240 Returns:
241 dict[str, Any]: Envelope containing MIME type statistics, metadata,
242 and total element count.
243 """
244 args = parser_period.parse_args()
245 start_date = args.get("start_date")
246 end_date = args.get("end_date")
248 stats = _get_stats_by_mime_type(start_date, end_date)
250 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByMimeTypeList")
253# ==========================
254# Utility functions
255# ==========================
256@cache.memoize(timeout=300)
257def stats_by_period(period: str, start_date: date | None = None, end_date: date | None = None) -> list[dict[str, str | float]]:
258 """
259 Compute aggregated download statistics by period (month or year).
261 Args:
262 period (str): Aggregation period, either 'month' or 'year'.
263 start_date (datetime.date | None): Optional start date filter.
264 end_date (datetime.date | None): Optional end date filter.
266 Returns:
267 list[dict[str, str | float]]: List of statistics including period, number of files,
268 and total size.
269 """
270 strftime_format = "%Y-%m" if period == "month" else "%Y"
271 # Build "where" expression
272 conditions = []
273 conditions.append(Download.finished != 0)
274 if start_date:
275 conditions.append(Download.finished >= start_date)
276 if end_date:
277 conditions.append(Download.finished <= end_date)
279 data = typed_peewee_dicts(
280 Download.select(
281 Download.id,
282 Download.finished,
283 fn.strftime(strftime_format, Download.finished).alias(period),
284 Download.seedbox_size,
285 )
286 .where(*conditions)
287 .order_by(Download.finished.desc())
288 .dicts()
289 )
291 tmp = {}
292 for download in data:
293 key = download[period]
294 size = download["seedbox_size"]
295 if not key or not size:
296 continue
297 if key not in tmp:
298 tmp[key] = {"files": 0, "total_size": 0.0}
299 tmp[key]["files"] += 1
300 tmp[key]["total_size"] += size
302 return [
303 {
304 period: key,
305 "files": tmp[key]["files"],
306 "total_size": utils.byte_to_gi(tmp[key]["total_size"]),
307 }
308 for key in sorted(tmp)
309 ]
312@cache.memoize(timeout=300)
313def _get_stats_by_mime_type(start_date: date | None, end_date: date | None) -> list[dict[str, object]]:
314 """
315 Fetch file download counts and total sizes grouped by MIME type.
317 Executes the database query to aggregate finished downloads count and sum up
318 their local sizes by MIME type, then caches the result using Flask-Caching memoization[cite: 3].
320 Args:
321 start_date (datetime.date | None): Optional start date filter.
322 end_date (datetime.date | None): Optional end date filter.
324 Returns:
325 list[dict[str, object]]: A list of dictionaries containing MIME types,
326 their associated total file counts, and total sizes in bytes[cite: 3].
327 """
328 return Download.get_stats_by_mime_type(start_date, end_date)
331@cache.memoize(timeout=300)
332def _get_stats() -> dict[str, object]:
333 """
334 Fetch raw download statistics within an optional date range from the database.
336 Executes the underlying DAO query and caches the result for 5 minutes (300 seconds)
337 using Flask-Caching memoization[cite: 1, 2, 3].
339 Args:
340 start_date (date | None): Optional start date filter for range querying[cite: 1].
341 end_date (date | None): Optional end date filter for range querying[cite: 1].
343 Returns:
344 list[dict[str, object]]: A list of dictionaries representing download statistics data[cite: 1, 2].
345 """
346 return Download.get_stats()