Coverage for src/lanraragi_api/api/tankoubon.py: 32%

62 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-22 23:19 +0000

1from typing import Any, cast 

2 

3from requests import Response 

4 

5from lanraragi_api.api.base import ( 

6 BaseAPICall, 

7) 

8from lanraragi_api.entity.base import OperationResponse 

9from lanraragi_api.entity.tankoubon import ( 

10 TankoubonDetailResponse, 

11 TankoubonListResponse, 

12 TankoubonMetadata, 

13) 

14from lanraragi_api.error import APIResponseDecodeError 

15 

16 

17class TankoubonAPI(BaseAPICall): 

18 """Endpoints related to Tankoubons. 

19 

20 Shared request and error behavior is documented on ``BaseAPICall``. 

21 """ 

22 

23 def get_tankoubon_list(self, page: int | None = None) -> TankoubonListResponse: 

24 """Get list of Tankoubons paginated. 

25 

26 The amount of tanks per page depends on the server 

27 ``archives_per_page`` setting. 

28 

29 Args: 

30 page: Page of the list of Tankoubons. Defaults to None. 

31 

32 Returns: 

33 TankoubonListResponse: Tankoubons of the requested page. 

34 

35 Raises: 

36 APIHttpError: Any non-2xx status code returned by the server. 

37 APIResponseDecodeError: If the response has no ``result`` list, or 

38 if an item does not match ``TankoubonMetadata``. 

39 """ 

40 path = "/api/tankoubons" 

41 payload = self.request_json("GET", path, params={"page": page}) 

42 result = payload.get("result") 

43 if not isinstance(result, list): 

44 raise APIResponseDecodeError(self._to_url(path), "missing result list") 

45 return TankoubonListResponse( 

46 result=[ 

47 self.parse_model(TankoubonMetadata, t, path) 

48 for t in cast(list[Any], result) 

49 ], 

50 total=payload.get("total"), 

51 filtered=payload.get("filtered"), 

52 ) 

53 

54 def get_all_tankoubons(self, page: int | None = None) -> list[TankoubonMetadata]: 

55 """Return only the tankoubon list of the paginated list endpoint. 

56 

57 This is a backward-compatible wrapper around ``get_tankoubon_list``. 

58 

59 Args: 

60 page: Page of the list of Tankoubons. Defaults to None. 

61 

62 Returns: 

63 list[TankoubonMetadata]: Tankoubons of the requested page. 

64 """ 

65 return self.get_tankoubon_list(page=page).result 

66 

67 def get_tankoubon_detail( 

68 self, 

69 id: str, 

70 ) -> TankoubonMetadata: 

71 """Get the details of the specified tankoubon ID. 

72 

73 Args: 

74 id: ID of the Tankoubon desired. 

75 

76 Returns: 

77 TankoubonMetadata: Metadata of the tankoubon. 

78 

79 Raises: 

80 APIHttpError: 400 if the server rejected the request, or any other 

81 non-2xx status code. 

82 APIResponseDecodeError: If the response does not match 

83 ``TankoubonMetadata``. 

84 """ 

85 path = f"/api/tankoubons/{id}" 

86 payload = self.request_json("GET", path) 

87 return self.parse_model(TankoubonMetadata, payload, path) 

88 

89 def get_tankoubon_full( 

90 self, 

91 id: str, 

92 page: int = -1, 

93 ) -> TankoubonDetailResponse: 

94 """Get the details of a tankoubon with paginated archive metadata. 

95 

96 The amount of archives per page depends on the server 

97 ``archives_per_page`` setting. 

98 

99 Args: 

100 id: ID of the Tankoubon desired. 

101 page: Page of the Archives list. Defaults to -1, which returns all 

102 archives. 

103 

104 Returns: 

105 TankoubonDetailResponse: Tankoubon metadata with ``full_data`` 

106 filled in. 

107 

108 Raises: 

109 APIHttpError: 400 if the server rejected the request, or any other 

110 non-2xx status code. 

111 APIResponseDecodeError: If the response has no ``result`` payload, 

112 or if it does not match ``TankoubonMetadata``. 

113 """ 

114 path = f"/api/tankoubons/{id}/full" 

115 payload = self.request_json( 

116 "GET", 

117 path, 

118 params={"page": page}, 

119 ) 

120 result = payload.get("result") 

121 if result is None: 

122 raise APIResponseDecodeError(self._to_url(path), "missing result payload") 

123 return TankoubonDetailResponse( 

124 result=self.parse_model(TankoubonMetadata, result, path), 

125 total=payload.get("total"), 

126 filtered=payload.get("filtered"), 

127 ) 

128 

129 def get_tankoubon(self, id: str) -> TankoubonMetadata: 

130 """Get the details of the specified tankoubon ID. 

131 

132 This is a backward-compatible wrapper around ``get_tankoubon_detail``. 

133 

134 Args: 

135 id: ID of the Tankoubon desired. 

136 

137 Returns: 

138 TankoubonMetadata: Metadata of the tankoubon. 

139 """ 

140 return self.get_tankoubon_detail(id=id) 

141 

142 def get_tankoubon_thumbnail( 

143 self, 

144 id: str, 

145 no_fallback: bool | None = None, 

146 ) -> Response: 

147 """Get the cover thumbnail for a given Tankoubon. 

148 

149 By default, the thumbnail is sourced from the first page of the first 

150 archive. This endpoint returns a placeholder image if the thumbnail 

151 does not exist yet. If you want to queue generation of the thumbnail in 

152 the background, you can use the ``no_fallback`` query parameter. 

153 

154 Args: 

155 id: ID of the Tankoubon desired. 

156 no_fallback: Disables the placeholder image, queues the thumbnail 

157 for extraction and returns a JSON with code 202. This parameter 

158 does nothing if the image already exists. Defaults to None. 

159 

160 Returns: 

161 Response: Response of the server, either the thumbnail bytes with 

162 code 200 or the job JSON with code 202. 

163 

164 Raises: 

165 APIHttpError: 400 if the server rejected the request, or any other 

166 non-2xx status code. 

167 

168 Note: 

169 When the thumbnail already exists, the image is returned with code 

170 200 no matter what. Otherwise, ``no_fallback`` queues the 

171 extraction and the 202 body carries a Minion job; use 

172 ``/api/minion/:jobid`` to track when the thumbnail is ready. 

173 """ 

174 no_fallback_value = None 

175 if no_fallback is not None: 

176 no_fallback_value = "true" if no_fallback else "false" 

177 

178 return self.request( 

179 "GET", 

180 f"/api/tankoubons/{id}/thumbnail", 

181 params={"no_fallback": no_fallback_value}, 

182 ) 

183 

184 def update_tankoubon_thumbnail(self, id: str, page: int) -> OperationResponse: 

185 """Set the cover thumbnail of a Tankoubon from a global page number. 

186 

187 The global page falls within all archives of the tank, in order, and is 

188 translated to the correct archive and local page automatically. 

189 

190 Args: 

191 id: ID of the Tankoubon desired. 

192 page: Global 1-indexed page number across all archives in the 

193 tankoubon. Page 1 is the first page of the first archive, and so 

194 on. 

195 

196 Returns: 

197 OperationResponse: Result of the operation. 

198 

199 Raises: 

200 APIResponseDecodeError: If the response body is not valid JSON, or 

201 does not match ``OperationResponse``. 

202 APIOperationError: If the operation failed and raising is enabled. 

203 

204 Note: 

205 On success the server also returns a ``new_thumbnail`` field with 

206 the path of the new thumbnail file. 

207 """ 

208 return self.request_operation( 

209 "PUT", 

210 f"/api/tankoubons/{id}/thumbnail", 

211 params={"page": page}, 

212 ) 

213 

214 def update_tank_progress(self, id: str, page: int) -> OperationResponse: 

215 """Tell the server which page of this Tankoubon you're reading. 

216 

217 The server updates its internal reading progression accordingly. The 

218 page number is global across all Archives in the tank (if a tank has 

219 two Archives with 20 and 25 pages, page 26 will be page 6 in Archive 

220 #2). 

221 

222 Args: 

223 id: ID of the Tankoubon to update. 

224 page: Global 1-indexed page number to update the reading progress 

225 to. Must be a positive integer. 

226 

227 Returns: 

228 OperationResponse: Result of the operation. 

229 

230 Raises: 

231 APIResponseDecodeError: If the response body is not valid JSON, or 

232 does not match ``OperationResponse``. 

233 APIOperationError: If the operation failed and raising is enabled. 

234 

235 Note: 

236 If the server is configured to use clientside progress tracking, 

237 this API call returns an error. Make sure to check through 

238 ``/api/info`` whether the server tracks reading progression or not 

239 before calling this endpoint. 

240 """ 

241 return self.request_operation("PUT", f"/api/tankoubons/{id}/progress/{page}") 

242 

243 def create_tankoubon( 

244 self, name: str, tankid: str | None = None 

245 ) -> OperationResponse: 

246 """Create a new Tankoubon or update the name of an existing one. 

247 

248 Args: 

249 name: Name of the Tankoubon. 

250 tankid: ID of an existing Tankoubon, if you want to change its 

251 name. Defaults to None, which creates a new Tankoubon. 

252 

253 Returns: 

254 OperationResponse: Result of the operation; ``tankoubon_id`` holds 

255 the ID of the created or modified Tankoubon. 

256 

257 Raises: 

258 APIResponseDecodeError: If the response body is not valid JSON, or 

259 does not match ``OperationResponse``. 

260 APIOperationError: If the operation failed and raising is enabled. 

261 """ 

262 return self.request_operation( 

263 "PUT", "/api/tankoubons", data={"name": name, "tankid": tankid} 

264 ) 

265 

266 def update_tankoubon( 

267 self, 

268 id: str, 

269 archives: list[str] | None = None, 

270 name: str | None = None, 

271 summary: str | None = None, 

272 tags: str | None = None, 

273 append: bool | None = None, 

274 metadata: dict[str, Any] | None = None, 

275 ) -> OperationResponse: 

276 """Modify the full metadata (name, summary, additional tags) or the 

277 contents of a Tankoubon. 

278 

279 If you only need to change the name of a Tank, consider just using 

280 ``PUT /api/tankoubons`` instead. 

281 

282 Args: 

283 id: ID of the Tankoubon to update. 

284 archives: Ordered list of archive IDs. Defaults to None. 

285 name: Name of the Tankoubon. Defaults to None. 

286 summary: Summary of the Tankoubon. Defaults to None. 

287 tags: Additional tags for the Tankoubon, in LRR comma-separated 

288 format. This replaces whatever additional tags the Tank already 

289 has, unless ``append`` is True. Defaults to None. 

290 append: If True, tags are appended to the Tank's existing own tags 

291 instead of replacing them. Defaults to None, which leaves the 

292 server default of False. 

293 metadata: Metadata payload, merged with the explicit arguments. 

294 Defaults to None. 

295 

296 Returns: 

297 OperationResponse: Result of the operation. 

298 

299 Raises: 

300 APIResponseDecodeError: If the response body is not valid JSON, or 

301 does not match ``OperationResponse``. 

302 APIOperationError: If the operation failed and raising is enabled. 

303 

304 Note: 

305 If there is no need to update something in one of the metadata 

306 keys, do not send the key, as this can otherwise result in unwanted 

307 results. 

308 """ 

309 payload: dict[str, Any] = {} 

310 if archives is not None: 

311 payload["archives"] = archives 

312 

313 metadata_payload = {} if metadata is None else dict(metadata) 

314 if name is not None: 

315 metadata_payload["name"] = name 

316 if summary is not None: 

317 metadata_payload["summary"] = summary 

318 if tags is not None: 

319 metadata_payload["tags"] = tags 

320 if append is not None: 

321 metadata_payload["append"] = append 

322 

323 if metadata_payload: 

324 payload["metadata"] = metadata_payload 

325 

326 return self.request_operation("PUT", f"/api/tankoubons/{id}", json=payload) 

327 

328 def add_archive_to_tankoubon( 

329 self, tankoubon_id: str, archive_id: str 

330 ) -> OperationResponse: 

331 """Append an archive at the final position of a Tankoubon. 

332 

333 Args: 

334 tankoubon_id: ID of the Tankoubon to update. 

335 archive_id: ID of the Archive to append. 

336 

337 Returns: 

338 OperationResponse: Result of the operation. 

339 

340 Raises: 

341 APIResponseDecodeError: If the response body is not valid JSON, or 

342 does not match ``OperationResponse``. 

343 APIOperationError: If the operation failed and raising is enabled. 

344 

345 Note: 

346 Failure responses such as 400 (error response) or 423 (Tankoubon 

347 locked for modification) are returned in the operation result, with 

348 ``success`` set to 0, instead of raising. 

349 """ 

350 return self.request_operation( 

351 "PUT", f"/api/tankoubons/{tankoubon_id}/{archive_id}" 

352 ) 

353 

354 def remove_archive_from_tankoubon( 

355 self, tankoubon_id: str, archive_id: str 

356 ) -> OperationResponse: 

357 """Remove an archive from a Tankoubon. 

358 

359 Args: 

360 tankoubon_id: ID of the Tankoubon to update. 

361 archive_id: ID of the archive to remove. 

362 

363 Returns: 

364 OperationResponse: Result of the operation. 

365 

366 Raises: 

367 APIResponseDecodeError: If the response body is not valid JSON, or 

368 does not match ``OperationResponse``. 

369 APIOperationError: If the operation failed and raising is enabled. 

370 

371 Note: 

372 Failure responses such as 400 (error response) or 423 (Tankoubon 

373 locked for modification) are returned in the operation result, with 

374 ``success`` set to 0, instead of raising. 

375 """ 

376 return self.request_operation( 

377 "DELETE", f"/api/tankoubons/{tankoubon_id}/{archive_id}" 

378 ) 

379 

380 def delete_tankoubon(self, id: str) -> OperationResponse: 

381 """Remove a Tankoubon from the server. 

382 

383 This doesn't delete the underlying Archives. 

384 

385 Args: 

386 id: ID of the Tankoubon to delete. 

387 

388 Returns: 

389 OperationResponse: Result of the operation. 

390 

391 Raises: 

392 APIResponseDecodeError: If the response body is not valid JSON, or 

393 does not match ``OperationResponse``. 

394 APIOperationError: If the operation failed and raising is enabled. 

395 

396 Note: 

397 Failure responses such as 400 (error response) or 423 (Tankoubon 

398 locked for modification) are returned in the operation result, with 

399 ``success`` set to 0, instead of raising. 

400 """ 

401 return self.request_operation("DELETE", f"/api/tankoubons/{id}")