Coverage for seedboxsync/front/apis/downloads_stats.py: 85%
87 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +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, timedelta
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 end_exclusive = end_date + timedelta(days=1)
278 conditions.append(Download.finished < end_exclusive)
280 data = typed_peewee_dicts(
281 Download.select(
282 Download.id,
283 Download.finished,
284 fn.strftime(strftime_format, Download.finished).alias(period),
285 Download.seedbox_size,
286 )
287 .where(*conditions)
288 .order_by(Download.finished.desc())
289 .dicts()
290 )
292 tmp = {}
293 for download in data:
294 key = download[period]
295 size = download["seedbox_size"]
296 if not key or not size:
297 continue
298 if key not in tmp:
299 tmp[key] = {"files": 0, "total_size": 0.0}
300 tmp[key]["files"] += 1
301 tmp[key]["total_size"] += size
303 return [
304 {
305 period: key,
306 "files": tmp[key]["files"],
307 "total_size": utils.byte_to_gi(tmp[key]["total_size"]),
308 }
309 for key in sorted(tmp)
310 ]
313@cache.memoize(timeout=300)
314def _get_stats_by_mime_type(start_date: date | None, end_date: date | None) -> list[dict[str, object]]:
315 """
316 Fetch file download counts and total sizes grouped by MIME type.
318 Executes the database query to aggregate finished downloads count and sum up
319 their local sizes by MIME type, then caches the result using Flask-Caching memoization[cite: 3].
321 Args:
322 start_date (datetime.date | None): Optional start date filter.
323 end_date (datetime.date | None): Optional end date filter.
325 Returns:
326 list[dict[str, object]]: A list of dictionaries containing MIME types,
327 their associated total file counts, and total sizes in bytes[cite: 3].
328 """
329 return Download.get_stats_by_mime_type(start_date, end_date)
332@cache.memoize(timeout=300)
333def _get_stats() -> dict[str, object]:
334 """
335 Fetch raw download statistics within an optional date range from the database.
337 Executes the underlying DAO query and caches the result for 5 minutes (300 seconds)
338 using Flask-Caching memoization[cite: 1, 2, 3].
340 Args:
341 start_date (date | None): Optional start date filter for range querying[cite: 1].
342 end_date (date | None): Optional end date filter for range querying[cite: 1].
344 Returns:
345 list[dict[str, object]]: A list of dictionaries representing download statistics data[cite: 1, 2].
346 """
347 return Download.get_stats()