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

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

18 

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

20 

21 

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) 

85 

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) 

108 

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) 

131 

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) 

159 

160 

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

199 

200 

201# ========================== 

202# Endpoints 

203# ========================== 

204@api.route("") 

205class DownloadsList(Resource): 

206 """ 

207 Endpoint for managing downloads list. 

208 

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

210 """ 

211 

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. 

219 

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

233 

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 ) 

257 

258 if search: 

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

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

261 

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) 

270 

271 if start_date: 

272 count = count.where(Download.finished >= start_date) 

273 select = select.where(Download.finished >= start_date) 

274 

275 if end_date: 

276 count = count.where(Download.finished <= end_date) 

277 select = select.where(Download.finished <= end_date) 

278 

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

280 

281 

282@api.route("/progress") 

283class DownloadsProgress(Resource): 

284 """Endpoint for managing downloads progress.""" 

285 

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

293 

294 

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. 

301 

302 Provides downloads operations. 

303 """ 

304 

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. 

311 

312 Args: 

313 id (int): Download identifier. 

314 

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

344 

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

346 

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. 

353 

354 Args: 

355 id (int): Download identifier. 

356 

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

363 

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

365 

366 

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

368class DownloadsStatsByMonth(Resource): 

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

370 

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. 

378 

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

384 

385 stats = stats_by_period("month", start_date, end_date) 

386 

387 return self.build_envelope(stats, data_total=len(stats), type="StatsMonth") 

388 

389 

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

391class DownloadsStatsByYear(Resource): 

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

393 

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. 

400 

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

402 """ 

403 stats = stats_by_period("year") 

404 

405 return self.build_envelope(stats, data_total=len(stats), type="StatsYear") 

406 

407 

408@api.route("/stats/mimetype") 

409class DownloadsStatsByMimeType(Resource): 

410 """Resource endpoint to retrieve download MIME type statistics.""" 

411 

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. 

419 

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. 

422 

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

430 

431 stats = _get_stats_by_mime_type(start_date, end_date) 

432 

433 return self.build_envelope(stats, data_total=len(stats), type="StatsMimeType") 

434 

435 

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

443 

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. 

448 

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) 

461 

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 ) 

473 

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 

484 

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 ] 

493 

494 

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. 

499 

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

502 

503 Args: 

504 start_date (datetime.date | None): Optional start date filter. 

505 end_date (datetime.date | None): Optional end date filter. 

506 

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)