Coverage for seedboxsync/front/apis/downloads.py: 91%
145 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"""SeedboxSync api error module."""
9from datetime import date
10from typing import Any
11from flask_restx import Namespace, fields, inputs, reqparse
12from peewee import fn
13from seedboxsync.core import utils
14from seedboxsync.core.database.models import Download, typed_peewee_dicts
15from seedboxsync.front.apis import DateTimeOrZero, Resource, parser_period
16from seedboxsync.front.cache import cache
17from seedboxsync.front.login_manager import login_required
19api = Namespace("downloads", description="Operations related to download management")
22# ==========================
23# Models
24# ==========================
25download_model = api.model(
26 "Download",
27 {
28 "id": fields.Integer(
29 required=True,
30 description="Unique identifier of the download record",
31 example=999,
32 ),
33 "path": fields.String(
34 required=True,
35 description="Local path of the downloaded file",
36 example="ConvallisMorbi.doc",
37 ),
38 "mime_extension": fields.String(
39 required=True,
40 description="File extension detected during MIME analysis",
41 example="doc",
42 ),
43 "mime_type": fields.String(
44 required=True,
45 description="MIME type detected for the file (e.g. application/msword)",
46 example="ConvallisMorbi.doc",
47 ),
48 "mime_confidence": fields.String(
49 required=True,
50 description="Confidence level or method used for MIME detection (e.g. extension, magic)",
51 example="puremagic (confidence: 1)",
52 ),
53 "started": fields.DateTime(dt_format="iso8601", required=True, description="Download start timestamp"),
54 "finished": DateTimeOrZero(
55 dt_format="iso8601",
56 required=False,
57 description="Download completion timestamp",
58 ),
59 "local_size": fields.Integer(
60 required=True,
61 description="File size on local storage in bytes",
62 example=3337353289,
63 ),
64 "human_local_size": fields.String(
65 required=True,
66 description="File size on local storage with related humanization",
67 example="3.1 GiB",
68 ),
69 "seedbox_size": fields.Integer(
70 required=True,
71 description="File size on seedbox storage in bytes",
72 example=3337353289,
73 ),
74 "human_seedbox_size": fields.String(
75 required=True,
76 description="File size on seedbox storage with related humanization",
77 example="3.1 GiB",
78 ),
79 "progress": fields.Float(required=True, description="Download progress percentage", example=15.0),
80 },
81)
82download_list_envelope = Resource.build_envelope_model(api, "DownloadList", nested_model=download_model)
83download_envelope = Resource.build_envelope_model(api, "Download", nested_model=download_model, as_list=False)
84download_message_envelope = Resource.build_envelope_model(api, "DownloadMessage", as_message=True)
86stats_month_model = api.model(
87 "StatsMonth",
88 {
89 "files": fields.Integer(
90 required=True,
91 description="Number of files downloaded in the month",
92 example=135,
93 ),
94 "month": fields.String(
95 required=True,
96 description="Year and month of the statistics (format: yyyy-mm)",
97 pattern=r"^\d{4}-(0[1-9]|1[0-2])$",
98 example="2025-08",
99 ),
100 "total_size": fields.String(
101 required=True,
102 description="Total size of files downloaded",
103 example="427.8GiB",
104 ),
105 },
106)
107stats_month_envelope = Resource.build_envelope_model(api, "StatsMonth", nested_model=stats_month_model)
109stats_year_model = api.model(
110 "StatsYear",
111 {
112 "files": fields.Integer(
113 required=True,
114 description="Number of files downloaded in the year",
115 example=4989,
116 ),
117 "year": fields.String(
118 required=True,
119 description="Year of the statistics (format: yyyy)",
120 pattern=r"^\d{4}$",
121 example="2018",
122 ),
123 "total_size": fields.String(
124 required=True,
125 description="Total size of files downloaded",
126 example="1476.5GiB",
127 ),
128 },
129)
130stats_year_envelope = Resource.build_envelope_model(api, "StatsYear", nested_model=stats_year_model)
132stats_mimetype_model = api.model(
133 "StatsMimeType",
134 {
135 "mime_type": fields.String(
136 required=True,
137 description="MIME type identifier detected for the files",
138 pattern=r"^[a-zA-Z0-9!#$&^_\-\+\.]+/[a-zA-Z0-9!#$&^_\-\+\.]+$",
139 example="video/x-matroska",
140 ),
141 "total": fields.Integer(
142 required=True,
143 description="Number or size of files downloaded for this MIME type",
144 example=4989,
145 ),
146 "total_size": fields.Integer(
147 required=True,
148 description="Size of files downloaded for this MIME type",
149 example=21678643250867,
150 ),
151 "human_total_size": fields.String(
152 required=True,
153 description="Total size of files downloaded with related humanization",
154 example="19.7 Tio",
155 ),
156 },
157)
158stats_mimetype_envelope = Resource.build_envelope_model(api, "StatsMimeType", nested_model=stats_mimetype_model)
161# ==========================
162# Request parser
163# ==========================
164parser = reqparse.RequestParser()
165parser.add_argument(
166 "offset",
167 type=int,
168 default=0,
169 location="args",
170 help="Number of items to skip before starting to collect the result set (default: 0)",
171)
172parser.add_argument(
173 "limit",
174 type=int,
175 default=50,
176 location="args",
177 help="Maximum number of items to return (min=5, max=1000)",
178)
179parser.add_argument(
180 "finished",
181 type=inputs.boolean,
182 default=None,
183 location="args",
184 help="Filter only completed downloads (true) or in-progress downloads (false)",
185)
186parser.add_argument(
187 "start_date",
188 type=inputs.date_from_iso8601,
189 location="args",
190 help="Start date for filtering in ISO 8601 format (e.g. YYYY-MM-DD)",
191)
192parser.add_argument(
193 "end_date",
194 type=inputs.date_from_iso8601,
195 location="args",
196 help="End date for filtering in ISO 8601 format (e.g. YYYY-MM-DD)",
197)
198parser.add_argument("search", type=str, required=False, help="Optional search string to filter items")
201# ==========================
202# Endpoints
203# ==========================
204@api.route("")
205class DownloadsList(Resource):
206 """
207 Endpoint for managing downloads list.
209 Provides a list of downloads with optional filtering for in-progress or completed files.
210 """
212 @api.doc("list_downloads") # type: ignore[untyped-decorator]
213 @api.expect(parser) # type: ignore[untyped-decorator]
214 @api.marshal_with(download_list_envelope, code=200, description="List of downloads") # type: ignore[untyped-decorator]
215 @login_required # type: ignore[untyped-decorator]
216 def get(self) -> dict[str, Any]:
217 """
218 Retrieve a list of recent downloads.
220 Query Parameters:
221 - offset: Number of items to skip before starting to collect the result set (default: 0)
222 - limit: Maximum number of downloads to return (default=50)
223 - search: Optional search string to filter items
224 - finished: Filter downloads by status (false=in-progress, true=finished)
225 """
226 args = parser.parse_args()
227 offset = args.get("offset")
228 limit = self.set_limit(args.get("limit", 50))
229 search = args.get("search")
230 finished = args.get("finished")
231 start_date = args.get("start_date")
232 end_date = args.get("end_date")
234 count = Download.select()
235 select = (
236 Download.select(
237 Download.id,
238 Download.path,
239 Download.mime_extension,
240 Download.mime_type,
241 Download.mime_confidence,
242 Download.started,
243 Download.finished,
244 Download.local_size,
245 Download.seedbox_size,
246 fn.humanize(Download.local_size).alias("human_local_size"),
247 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"),
248 fn.round(
249 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100,
250 2,
251 ).alias("progress"),
252 )
253 .limit(limit)
254 .offset(offset)
255 .order_by(Download.finished.desc())
256 )
258 if search:
259 count = count.where(Download.path.contains(search))
260 select = select.where(Download.path.contains(search))
262 if finished is not None:
263 # Filter downloads by completion status
264 if finished:
265 count = count.where(Download.finished != 0)
266 select = select.where(Download.finished != 0)
267 else:
268 count = count.where(Download.finished == 0)
269 select = select.where(Download.finished == 0)
271 if start_date:
272 count = count.where(Download.finished >= start_date)
273 select = select.where(Download.finished >= start_date)
275 if end_date:
276 count = count.where(Download.finished <= end_date)
277 select = select.where(Download.finished <= end_date)
279 return self.build_envelope(list(select.dicts()), data_total=count.count(), type="Download")
282@api.route("/progress")
283class DownloadsProgress(Resource):
284 """Endpoint for managing downloads progress."""
286 @api.doc("delete_downloads_progress") # type: ignore[untyped-decorator]
287 @api.marshal_with(download_message_envelope, code=200, description="Downloads in progress deleted") # type: ignore[untyped-decorator]
288 @login_required # type: ignore[untyped-decorator]
289 def delete(self) -> dict[str, Any]:
290 """Delete progress downloads."""
291 count = Download.delete().where(Download.finished == 0).execute()
292 return self.build_envelope(None, type="Download", message=f"{count} download(s) deleted.")
295@api.route("/<int:id>")
296@api.response(404, "Download not found")
297@api.param("id", "The download identifier")
298class Downloads(Resource):
299 """
300 Endpoint for managing downloads.
302 Provides downloads operations.
303 """
305 @api.doc("get_download") # type: ignore[untyped-decorator]
306 @api.marshal_with(download_envelope, skip_none=True, code=200, description="Download element") # type: ignore[untyped-decorator]
307 @login_required # type: ignore[untyped-decorator]
308 def get(self, id: int) -> dict[str, Any]: # noqa: A002
309 """
310 Retrieve a download.
312 Args:
313 id (int): Download identifier.
315 Returns:
316 dict[str, Any]: API response envelope containing the download.
317 """
318 select: Download | None = None
319 try:
320 select = (
321 Download.select(
322 Download.id,
323 Download.path,
324 Download.mime_extension,
325 Download.mime_type,
326 Download.mime_confidence,
327 Download.started,
328 Download.finished,
329 Download.local_size,
330 Download.seedbox_size,
331 fn.humanize(Download.local_size).alias("human_local_size"),
332 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"),
333 fn.round(
334 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100,
335 2,
336 ).alias("progress"),
337 )
338 .where(Download.id == id)
339 .dicts()
340 .get()
341 )
342 except Download.DoesNotExist: # type: ignore[attr-defined]
343 api.abort(404, f"Download {id} doesn't exist")
345 return self.build_envelope(select, type="Download")
347 @api.doc("delete_download") # type: ignore[untyped-decorator]
348 @api.marshal_with(download_message_envelope, code=200, description="Delete download element") # type: ignore[untyped-decorator]
349 @login_required # type: ignore[untyped-decorator]
350 def delete(self, id: int) -> dict[str, Any]: # noqa: A002
351 """
352 Delete a download.
354 Args:
355 id (int): Download identifier.
357 Returns:
358 dict[str, Any]: API response envelope containing a status message.
359 """
360 count = Download.delete().where(Download.id == id).execute()
361 if count == 0:
362 api.abort(404, f"Download {id} doesn't exist")
364 return self.build_envelope(None, type="Download", message=f"Download {id} deleted.")
367@api.route("/stats/month")
368class DownloadsStatsByMonth(Resource):
369 """Endpoint to retrieve monthly download statistics."""
371 @api.doc("stats_downloads_by_month") # type: ignore[untyped-decorator]
372 @api.marshal_with(stats_month_envelope, code=200, description="Download statistics aggregated by month") # type: ignore[untyped-decorator]
373 @api.expect(parser_period) # type: ignore[untyped-decorator]
374 @login_required # type: ignore[untyped-decorator]
375 def get(self) -> dict[str, Any]:
376 """
377 Return download statistics grouped by month.
379 Returns the number of files downloaded and total size per month.
380 """
381 args = parser_period.parse_args()
382 start_date = args.get("start_date")
383 end_date = args.get("end_date")
385 stats = stats_by_period("month", start_date, end_date)
387 return self.build_envelope(stats, data_total=len(stats), type="StatsMonth")
390@api.route("/stats/year")
391class DownloadsStatsByYear(Resource):
392 """Endpoint to retrieve yearly download statistics."""
394 @api.doc("stats_downloads_by_year") # type: ignore[untyped-decorator]
395 @api.marshal_with(stats_year_envelope, code=200, description="Download statistics aggregated by year") # type: ignore[untyped-decorator]
396 @login_required # type: ignore[untyped-decorator]
397 def get(self) -> dict[str, Any]:
398 """
399 Return download statistics grouped by year.
401 Returns the number of files downloaded and total size per year.
402 """
403 stats = stats_by_period("year")
405 return self.build_envelope(stats, data_total=len(stats), type="StatsYear")
408@api.route("/stats/mimetype")
409class DownloadsStatsByMimeType(Resource):
410 """Resource endpoint to retrieve download MIME type statistics."""
412 @api.doc("stats_downloads_by_mimetype") # type: ignore[untyped-decorator]
413 @api.marshal_with(stats_mimetype_envelope, code=200, description="Download statistics aggregated by mimetype") # type: ignore[untyped-decorator]
414 @api.expect(parser_period) # type: ignore[untyped-decorator]
415 @login_required # type: ignore[untyped-decorator]
416 def get(self) -> dict[str, Any]:
417 """
418 Retrieve download statistics grouped by MIME type.
420 Fetches aggregated file counts and total sizes per MIME type from the cache
421 or database, then wraps the dataset into a standard API response envelope.
423 Returns:
424 dict[str, Any]: Envelope containing MIME type statistics, metadata,
425 and total element count.
426 """
427 args = parser_period.parse_args()
428 start_date = args.get("start_date")
429 end_date = args.get("end_date")
431 stats = _get_stats_by_mime_type(start_date, end_date)
433 return self.build_envelope(stats, data_total=len(stats), type="StatsMimeType")
436# ==========================
437# Utility functions
438# ==========================
439@cache.memoize(timeout=300)
440def stats_by_period(period: str, start_date: date | None = None, end_date: date | None = None) -> list[dict[str, str | float]]:
441 """
442 Compute aggregated download statistics by period (month or year).
444 Args:
445 period (str): Aggregation period, either 'month' or 'year'.
446 start_date (datetime.date | None): Optional start date filter.
447 end_date (datetime.date | None): Optional end date filter.
449 Returns:
450 list[dict[str, str | float]]: List of statistics including period, number of files,
451 and total size.
452 """
453 strftime_format = "%Y-%m" if period == "month" else "%Y"
454 # Build "where" expression
455 conditions = []
456 conditions.append(Download.finished != 0)
457 if start_date:
458 conditions.append(Download.finished >= start_date)
459 if end_date:
460 conditions.append(Download.finished <= end_date)
462 data = typed_peewee_dicts(
463 Download.select(
464 Download.id,
465 Download.finished,
466 fn.strftime(strftime_format, Download.finished).alias(period),
467 Download.seedbox_size,
468 )
469 .where(*conditions)
470 .order_by(Download.finished.desc())
471 .dicts()
472 )
474 tmp = {}
475 for download in data:
476 key = download[period]
477 size = download["seedbox_size"]
478 if not key or not size:
479 continue
480 if key not in tmp:
481 tmp[key] = {"files": 0, "total_size": 0.0}
482 tmp[key]["files"] += 1
483 tmp[key]["total_size"] += size
485 return [
486 {
487 period: key,
488 "files": tmp[key]["files"],
489 "total_size": utils.byte_to_gi(tmp[key]["total_size"]),
490 }
491 for key in sorted(tmp)
492 ]
495@cache.memoize(timeout=300)
496def _get_stats_by_mime_type(start_date: date | None, end_date: date | None) -> list[dict[str, object]]:
497 """
498 Fetch file download counts and total sizes grouped by MIME type.
500 Executes the database query to aggregate finished downloads count and sum up
501 their local sizes by MIME type, then caches the result using Flask-Caching memoization[cite: 3].
503 Args:
504 start_date (datetime.date | None): Optional start date filter.
505 end_date (datetime.date | None): Optional end date filter.
507 Returns:
508 list[dict[str, object]]: A list of dictionaries containing MIME types,
509 their associated total file counts, and total sizes in bytes[cite: 3].
510 """
511 return Download.get_stats_by_mime_type(start_date, end_date)