Coverage for seedboxsync/front/apis/downloads.py: 95%

79 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-11 07:37 +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.database.models import Download 

13from seedboxsync.front.apis import DateTimeOrZero, Resource, parser_period 

14from seedboxsync.front.login_manager import login_required 

15 

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

17 

18 

19# ========================== 

20# Models 

21# ========================== 

22download_model = api.model( 

23 "Download", 

24 { 

25 "id": fields.Integer( 

26 required=True, 

27 description="Unique identifier of the download record", 

28 example=999, 

29 ), 

30 "path": fields.String( 

31 required=True, 

32 description="Local path of the downloaded file", 

33 example="ConvallisMorbi.doc", 

34 ), 

35 "mime_extension": fields.String( 

36 required=True, 

37 description="File extension detected during MIME analysis", 

38 example="doc", 

39 ), 

40 "mime_type": fields.String( 

41 required=True, 

42 description="MIME type detected for the file (e.g. application/msword)", 

43 example="ConvallisMorbi.doc", 

44 ), 

45 "mime_confidence": fields.String( 

46 required=True, 

47 description="Confidence level or method used for MIME detection (e.g. extension, magic)", 

48 example="puremagic (confidence: 1)", 

49 ), 

50 "started": fields.DateTime(dt_format="iso8601", required=True, description="Download start timestamp"), 

51 "finished": DateTimeOrZero( 

52 dt_format="iso8601", 

53 required=False, 

54 description="Download completion timestamp", 

55 ), 

56 "local_size": fields.Integer( 

57 required=True, 

58 description="File size on local storage in bytes", 

59 example=3337353289, 

60 ), 

61 "human_local_size": fields.String( 

62 required=True, 

63 description="File size on local storage with related humanization", 

64 example="3.1 GiB", 

65 ), 

66 "seedbox_size": fields.Integer( 

67 required=True, 

68 description="File size on seedbox storage in bytes", 

69 example=3337353289, 

70 ), 

71 "human_seedbox_size": fields.String( 

72 required=True, 

73 description="File size on seedbox storage with related humanization", 

74 example="3.1 GiB", 

75 ), 

76 "progress": fields.Float(required=True, description="Download progress percentage", example=15.0), 

77 }, 

78) 

79download_list_envelope = Resource.build_envelope_model(api, "DownloadList", nested_model=download_model) 

80download_envelope = Resource.build_envelope_model(api, "Download", nested_model=download_model, as_list=False) 

81download_message_envelope = Resource.build_envelope_model(api, "DownloadMessage", as_message=True) 

82 

83 

84# ========================== 

85# Request parser 

86# ========================== 

87parser = reqparse.RequestParser() 

88parser.add_argument( 

89 "offset", 

90 type=int, 

91 default=0, 

92 location="args", 

93 help="Number of items to skip before starting to collect the result set (default: 0)", 

94) 

95parser.add_argument( 

96 "limit", 

97 type=int, 

98 default=50, 

99 location="args", 

100 help="Maximum number of items to return (min=5, max=1000)", 

101) 

102parser.add_argument( 

103 "finished", 

104 type=inputs.boolean, 

105 default=None, 

106 location="args", 

107 help="Filter only completed downloads (true) or in-progress downloads (false)", 

108) 

109parser.add_argument("search", type=str, required=False, help="Optional search string to filter items") 

110parser.args.extend(parser_period.args) 

111 

112 

113# ========================== 

114# Endpoints 

115# ========================== 

116@api.route("") 

117class DownloadsList(Resource): 

118 """ 

119 Endpoint for managing downloads list. 

120 

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

122 """ 

123 

124 @api.doc("list_downloads") # type: ignore[untyped-decorator] 

125 @api.expect(parser) # type: ignore[untyped-decorator] 

126 @api.marshal_with(download_list_envelope, code=200, description="List of downloads") # type: ignore[untyped-decorator] 

127 @login_required # type: ignore[untyped-decorator] 

128 def get(self) -> dict[str, Any]: 

129 """ 

130 Retrieve a list of recent downloads. 

131 

132 Query Parameters: 

133 - offset: Number of items to skip before starting to collect the result set (default: 0) 

134 - limit: Maximum number of downloads to return (default=50) 

135 - search: Optional search string to filter items 

136 - finished: Filter downloads by status (false=in-progress, true=finished) 

137 """ 

138 args = parser.parse_args() 

139 offset = args.get("offset") 

140 limit = self.set_limit(args.get("limit", 50)) 

141 search = args.get("search") 

142 finished = args.get("finished") 

143 start_date = args.get("start_date") 

144 end_date = args.get("end_date") 

145 

146 count = Download.select() 

147 select = ( 

148 Download.select( 

149 Download.id, 

150 Download.path, 

151 Download.mime_extension, 

152 Download.mime_type, 

153 Download.mime_confidence, 

154 Download.started, 

155 Download.finished, 

156 Download.local_size, 

157 Download.seedbox_size, 

158 fn.humanize(Download.local_size).alias("human_local_size"), 

159 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"), 

160 fn.round( 

161 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100, 

162 2, 

163 ).alias("progress"), 

164 ) 

165 .limit(limit) 

166 .offset(offset) 

167 .order_by(Download.finished.desc()) 

168 ) 

169 

170 if search: 

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

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

173 

174 if finished is not None: 

175 # Filter downloads by completion status 

176 if finished: 

177 count = count.where(Download.finished != 0) 

178 select = select.where(Download.finished != 0) 

179 else: 

180 count = count.where(Download.finished == 0) 

181 select = select.where(Download.finished == 0) 

182 

183 if start_date: 

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

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

186 

187 if end_date: 

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

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

190 

191 return self.build_envelope(list(select.dicts()), data_total=count.count(), type="DownloadList") 

192 

193 

194@api.route("/progress") 

195class DownloadsProgress(Resource): 

196 """Endpoint for managing downloads progress.""" 

197 

198 @api.doc("delete_downloads_progress") # type: ignore[untyped-decorator] 

199 @api.marshal_with(download_message_envelope, code=200, description="Downloads in progress deleted") # type: ignore[untyped-decorator] 

200 @login_required # type: ignore[untyped-decorator] 

201 def delete(self) -> dict[str, Any]: 

202 """Delete progress downloads.""" 

203 count = Download.delete().where(Download.finished == 0).execute() 

204 return self.build_envelope(None, type="Download", message=f"{count} download(s) deleted.") 

205 

206 

207@api.route("/<int:id>") 

208@api.response(404, "Download not found") 

209@api.param("id", "The download identifier") 

210class Downloads(Resource): 

211 """ 

212 Endpoint for managing downloads. 

213 

214 Provides downloads operations. 

215 """ 

216 

217 @api.doc("get_download") # type: ignore[untyped-decorator] 

218 @api.marshal_with(download_envelope, skip_none=True, code=200, description="Download element") # type: ignore[untyped-decorator] 

219 @login_required # type: ignore[untyped-decorator] 

220 def get(self, id: int) -> dict[str, Any]: # noqa: A002 

221 """ 

222 Retrieve a download. 

223 

224 Args: 

225 id (int): Download identifier. 

226 

227 Returns: 

228 dict[str, Any]: API response envelope containing the download. 

229 """ 

230 select: Download | None = None 

231 try: 

232 select = ( 

233 Download.select( 

234 Download.id, 

235 Download.path, 

236 Download.mime_extension, 

237 Download.mime_type, 

238 Download.mime_confidence, 

239 Download.started, 

240 Download.finished, 

241 Download.local_size, 

242 Download.seedbox_size, 

243 fn.humanize(Download.local_size).alias("human_local_size"), 

244 fn.humanize(Download.seedbox_size).alias("human_seedbox_size"), 

245 fn.round( 

246 (Download.local_size.cast("REAL") / Download.seedbox_size.cast("REAL")) * 100, 

247 2, 

248 ).alias("progress"), 

249 ) 

250 .where(Download.id == id) 

251 .dicts() 

252 .get() 

253 ) 

254 except Download.DoesNotExist: # type: ignore[attr-defined] 

255 api.abort(404, f"Download {id} doesn't exist") 

256 

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

258 

259 @api.doc("delete_download") # type: ignore[untyped-decorator] 

260 @api.marshal_with(download_message_envelope, code=200, description="Delete download element") # type: ignore[untyped-decorator] 

261 @login_required # type: ignore[untyped-decorator] 

262 def delete(self, id: int) -> dict[str, Any]: # noqa: A002 

263 """ 

264 Delete a download. 

265 

266 Args: 

267 id (int): Download identifier. 

268 

269 Returns: 

270 dict[str, Any]: API response envelope containing a status message. 

271 """ 

272 count = Download.delete().where(Download.id == id).execute() 

273 if count == 0: 

274 api.abort(404, f"Download {id} doesn't exist") 

275 

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