Coverage for seedboxsync/front/apis/downloads.py: 95%
99 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +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 typing import Any
10from flask_restx import Namespace, fields, inputs, reqparse
11from peewee import fn
12from seedboxsync.core import utils
13from seedboxsync.core.dao import Download, typed_peewee_dicts
14from seedboxsync.front.apis import DateTimeOrZero, Resource
15from seedboxsync.front.cache import cache
17api = Namespace("downloads", description="Operations related to download management")
20# ==========================
21# Models
22# ==========================
23download_model = api.model(
24 "Download",
25 {
26 "id": fields.Integer(
27 required=True,
28 description="Unique identifier of the download record",
29 example=999,
30 ),
31 "path": fields.String(
32 required=True,
33 description="Local path of the downloaded file",
34 example="ConvallisMorbi.doc",
35 ),
36 "started": fields.DateTime(dt_format="iso8601", required=True, description="Download start timestamp"),
37 "finished": DateTimeOrZero(
38 dt_format="iso8601",
39 required=False,
40 description="Download completion timestamp",
41 ),
42 "local_size": fields.Integer(
43 required=True,
44 description="File size on local storage in bytes",
45 example=3337353289,
46 ),
47 "human_local_size": fields.String(
48 required=True,
49 description="File size on local storage with related humanization",
50 example="3.1 GiB",
51 ),
52 "seedbox_size": fields.Integer(
53 required=True,
54 description="File size on seedbox storage in bytes",
55 example=3337353289,
56 ),
57 "human_seedbox_size": fields.String(
58 required=True,
59 description="File size on seedbox storage with related humanization",
60 example="3.1 GiB",
61 ),
62 "progress": fields.Float(required=True, description="Download progress percentage", example=15.0),
63 },
64)
65download_list_envelope = Resource.build_envelope_model(api, "DownloadList", nested_model=download_model)
66download_envelope = Resource.build_envelope_model(api, "Download", nested_model=download_model, as_list=False)
67download_message_envelope = Resource.build_envelope_model(api, "DownloadMessage", as_message=True)
69stats_month_model = api.model(
70 "StatsMonth",
71 {
72 "files": fields.Integer(
73 required=True,
74 description="Number of files downloaded in the month",
75 example=135,
76 ),
77 "month": fields.String(
78 required=True,
79 description="Year and month of the statistics (format: yyyy-mm)",
80 pattern=r"^\d{4}-(0[1-9]|1[0-2])$",
81 example="2025-08",
82 ),
83 "total_size": fields.String(
84 required=True,
85 description="Total size of files downloaded",
86 example="427.8GiB",
87 ),
88 },
89)
90stats_month_envelope = Resource.build_envelope_model(api, "StatsMonth", nested_model=stats_month_model)
92stats_year_model = api.model(
93 "StatsYear",
94 {
95 "files": fields.Integer(
96 required=True,
97 description="Number of files downloaded in the year",
98 example=4989,
99 ),
100 "year": fields.String(
101 required=True,
102 description="Year of the statistics (format: yyyy)",
103 pattern=r"^\d{4}$",
104 example="2018",
105 ),
106 "total_size": fields.String(
107 required=True,
108 description="Total size of files downloaded",
109 example="1476.5GiB",
110 ),
111 },
112)
113stats_year_envelope = Resource.build_envelope_model(api, "StatsYear", nested_model=stats_year_model)
116# ==========================
117# Request parser
118# ==========================
119parser = reqparse.RequestParser()
120parser.add_argument(
121 "offset",
122 type=int,
123 default=0,
124 location="args",
125 help="Number of items to skip before starting to collect the result set (default: 0)",
126)
127parser.add_argument(
128 "limit",
129 type=int,
130 default=50,
131 location="args",
132 help="Maximum number of items to return (min=5, max=1000)",
133)
134parser.add_argument(
135 "finished",
136 type=inputs.boolean,
137 default=None,
138 location="args",
139 help="Filter only completed downloads (true) or in-progress downloads (false)",
140)
141parser.add_argument("search", type=str, required=False, help="Optional search string to filter items")
144# ==========================
145# Endpoints
146# ==========================
147@api.route("")
148class DownloadsList(Resource):
149 """
150 Endpoint for managing downloads list.
152 Provides a list of downloads with optional filtering for in-progress or completed files.
153 """
155 @api.doc("list_downloads") # type: ignore[untyped-decorator]
156 @api.expect(parser) # type: ignore[untyped-decorator]
157 @api.marshal_with(download_list_envelope, code=200, description="List of downloads") # type: ignore[untyped-decorator]
158 def get(self) -> dict[str, Any]:
159 """
160 Retrieve a list of recent downloads.
162 Query Parameters:
163 - offset: Number of items to skip before starting to collect the result set (default: 0)
164 - limit: Maximum number of downloads to return (default=50)
165 - search: Optional search string to filter items
166 - finished: Filter downloads by status (false=in-progress, true=finished)
167 """
168 args = parser.parse_args()
169 offset = args.get("offset")
170 limit = self.set_limit(args.get("limit", 50))
171 search = args.get("search")
172 finished = args.get("finished")
174 count = Download.select()
175 select = (
176 Download.select(
177 Download.id,
178 Download.path,
179 Download.started,
180 Download.finished,
181 Download.local_size,
182 Download.seedbox_size,
183 fn.humanize(Download.local_size).alias("human_local_size"),
184 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"),
185 fn.round(
186 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100,
187 2,
188 ).alias("progress"),
189 )
190 .limit(limit)
191 .offset(offset)
192 .order_by(Download.finished.desc())
193 )
195 if search:
196 count = count.where(Download.path.contains(search))
197 select = select.where(Download.path.contains(search))
199 if finished is not None:
200 # Filter downloads by completion status
201 if finished:
202 count = count.where(Download.finished != 0)
203 select = select.where(Download.finished != 0)
204 else:
205 count = count.where(Download.finished == 0)
206 select = select.where(Download.finished == 0)
208 return self.build_envelope(list(select.dicts()), data_total=count.count(), type="Download")
211@api.route("/progress")
212class DownloadsProgress(Resource):
213 """Endpoint for managing downloads progress."""
215 @api.doc("delete_downloads_progress") # type: ignore[untyped-decorator]
216 @api.marshal_with(download_message_envelope, code=200, description="Downloads in progress deleted") # type: ignore[untyped-decorator]
217 def delete(self) -> dict[str, Any]:
218 """Delete progress downloads."""
219 count = Download.delete().where(Download.finished == 0).execute()
220 return self.build_envelope(None, type="Download", message=f"{count} download(s) deleted.")
223@api.route("/<int:id>")
224@api.response(404, "Download not found")
225@api.param("id", "The download identifier")
226class Downloads(Resource):
227 """
228 Endpoint for managing downloads.
230 Provides downloads operations.
231 """
233 @api.doc("get_download") # type: ignore[untyped-decorator]
234 @api.marshal_with(download_envelope, code=200, description="Download element") # type: ignore[untyped-decorator]
235 def get(self, id: int) -> dict[str, Any]: # noqa: A002
236 """
237 Retrieve a download.
239 Args:
240 id (int): Download identifier.
242 Returns:
243 dict[str, Any]: API response envelope containing the download.
244 """
245 select: Download | None = None
246 try:
247 select = (
248 Download.select(
249 Download.id,
250 Download.path,
251 Download.started,
252 Download.finished,
253 Download.local_size,
254 Download.seedbox_size,
255 fn.humanize(Download.local_size).alias("human_local_size"),
256 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"),
257 fn.round(
258 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100,
259 2,
260 ).alias("progress"),
261 )
262 .where(Download.id == id)
263 .dicts()
264 .get()
265 )
266 except Download.DoesNotExist: # type: ignore[attr-defined]
267 api.abort(404, f"Download {id} doesn't exist")
269 return self.build_envelope(select, type="Download")
271 @api.doc("delete_download") # type: ignore[untyped-decorator]
272 @api.marshal_with(download_message_envelope, code=200, description="Delete download element") # type: ignore[untyped-decorator]
273 def delete(self, id: int) -> dict[str, Any]: # noqa: A002
274 """
275 Delete a download.
277 Args:
278 id (int): Download identifier.
280 Returns:
281 dict[str, Any]: API response envelope containing a status message.
282 """
283 count = Download.delete().where(Download.id == id).execute()
284 if count == 0:
285 api.abort(404, f"Download {id} doesn't exist")
287 return self.build_envelope(None, type="Download", message=f"Download {id} deleted.")
290@api.route("/stats/month")
291class DownloadsStatsByMonth(Resource):
292 """Endpoint to retrieve monthly download statistics."""
294 @cache.cached(timeout=3600) # pyright: ignore [reportUntypedFunctionDecorator]
295 @api.doc("stats_downloads_by_month") # type: ignore[untyped-decorator]
296 @api.marshal_with(stats_month_envelope, code=200, description="Download statistics aggregated by month") # type: ignore[untyped-decorator]
297 def get(self) -> dict[str, Any]:
298 """
299 Return download statistics grouped by month.
301 Returns the number of files downloaded and total size per month.
302 """
303 return self.build_envelope(stats_by_period("month"), type="StatsMonth")
306@api.route("/stats/year")
307class DownloadsStatsByYear(Resource):
308 """Endpoint to retrieve yearly download statistics."""
310 @cache.cached(timeout=3600) # pyright: ignore [reportUntypedFunctionDecorator]
311 @api.doc("stats_downloads_by_year") # type: ignore[untyped-decorator]
312 @api.marshal_with(stats_year_envelope, code=200, description="Download statistics aggregated by year") # type: ignore[untyped-decorator]
313 def get(self) -> dict[str, Any]:
314 """
315 Return download statistics grouped by year.
317 Returns the number of files downloaded and total size per year.
318 """
319 return self.build_envelope(stats_by_period("year"), type="StatsYear")
322# ==========================
323# Utility functions
324# ==========================
325def stats_by_period(period: str) -> list[dict[str, str | float]]:
326 """
327 Compute aggregated download statistics by period (month or year).
329 Args:
330 period (str): Aggregation period, either 'month' or 'year'.
332 Returns:
333 list[dict[str, str | float]]: List of statistics including period, number of files,
334 and total size.
335 """
336 strftime_format = "%Y-%m" if period == "month" else "%Y"
338 data = typed_peewee_dicts(
339 Download.select(
340 Download.id,
341 Download.finished,
342 fn.strftime(strftime_format, Download.finished).alias(period),
343 Download.seedbox_size,
344 )
345 .where(Download.finished != 0)
346 .order_by(Download.finished.desc())
347 .dicts()
348 )
350 tmp = {}
351 for download in data:
352 key = download[period]
353 size = download["seedbox_size"]
354 if not key or not size:
355 continue
356 if key not in tmp:
357 tmp[key] = {"files": 0, "total_size": 0.0}
358 tmp[key]["files"] += 1
359 tmp[key]["total_size"] += size
361 return [
362 {
363 period: key,
364 "files": tmp[key]["files"],
365 "total_size": utils.byte_to_gi(tmp[key]["total_size"]),
366 }
367 for key in sorted(tmp)
368 ]