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

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.""" 

8 

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 

16 

17api = Namespace("downloads", description="Operations related to download management") 

18 

19 

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) 

68 

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) 

91 

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) 

114 

115 

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") 

142 

143 

144# ========================== 

145# Endpoints 

146# ========================== 

147@api.route("") 

148class DownloadsList(Resource): 

149 """ 

150 Endpoint for managing downloads list. 

151 

152 Provides a list of downloads with optional filtering for in-progress or completed files. 

153 """ 

154 

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. 

161 

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") 

173 

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 ) 

194 

195 if search: 

196 count = count.where(Download.path.contains(search)) 

197 select = select.where(Download.path.contains(search)) 

198 

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) 

207 

208 return self.build_envelope(list(select.dicts()), data_total=count.count(), type="Download") 

209 

210 

211@api.route("/progress") 

212class DownloadsProgress(Resource): 

213 """Endpoint for managing downloads progress.""" 

214 

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.") 

221 

222 

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. 

229 

230 Provides downloads operations. 

231 """ 

232 

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. 

238 

239 Args: 

240 id (int): Download identifier. 

241 

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") 

268 

269 return self.build_envelope(select, type="Download") 

270 

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. 

276 

277 Args: 

278 id (int): Download identifier. 

279 

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") 

286 

287 return self.build_envelope(None, type="Download", message=f"Download {id} deleted.") 

288 

289 

290@api.route("/stats/month") 

291class DownloadsStatsByMonth(Resource): 

292 """Endpoint to retrieve monthly download statistics.""" 

293 

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. 

300 

301 Returns the number of files downloaded and total size per month. 

302 """ 

303 return self.build_envelope(stats_by_period("month"), type="StatsMonth") 

304 

305 

306@api.route("/stats/year") 

307class DownloadsStatsByYear(Resource): 

308 """Endpoint to retrieve yearly download statistics.""" 

309 

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. 

316 

317 Returns the number of files downloaded and total size per year. 

318 """ 

319 return self.build_envelope(stats_by_period("year"), type="StatsYear") 

320 

321 

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). 

328 

329 Args: 

330 period (str): Aggregation period, either 'month' or 'year'. 

331 

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" 

337 

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 ) 

349 

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 

360 

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 ]