Coverage for src/lanraragi_api/api/archive.py: 33%

93 statements  

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

1import os 

2from os.path import isfile 

3from typing import Any, cast 

4 

5from requests import Response 

6 

7from lanraragi_api.api.base import ( 

8 BaseAPICall, 

9) 

10from lanraragi_api.entity.archive import ArchiveMetadata 

11from lanraragi_api.entity.base import OperationResponse 

12from lanraragi_api.entity.category import CategoryMetadata 

13from lanraragi_api.entity.minion import MinionJobResponse 

14from lanraragi_api.error import APIResponseDecodeError 

15 

16 

17class ArchiveAPI(BaseAPICall): 

18 """Everything dealing with Archives. 

19 

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

21 """ 

22 

23 def get_all_archives(self) -> list[ArchiveMetadata]: 

24 """Return a list of all Archives in the database. 

25 

26 This doesn't include Tankoubons by design. You can use the IDs of this 

27 JSON with the other endpoints. 

28 

29 Returns: 

30 list[ArchiveMetadata]: Metadata of every Archive in the database. 

31 

32 Raises: 

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

34 APIResponseDecodeError: If the response body is not a list of 

35 objects matching ``ArchiveMetadata``. 

36 """ 

37 return self.request_model_list("GET", "/api/archives", ArchiveMetadata) 

38 

39 def get_archive(self, id: str) -> ArchiveMetadata | None: 

40 """Get Metadata (title, tags) for a given Archive. 

41 

42 Args: 

43 id: ID of the Archive to process. 

44 

45 Returns: 

46 ArchiveMetadata | None: Metadata of the archive, or None when the 

47 server answers with 400. 

48 

49 Raises: 

50 APIHttpError: Any status code other than 200 and 400. 

51 

52 Note: 

53 This endpoint is deprecated; use ``get_archive_metadata`` instead. 

54 The 400 response, sent when no archive ID was given, is turned into 

55 None instead of raising. 

56 """ 

57 path = f"/api/archives/{id}" 

58 resp = self.request("GET", path, expected_statuses={200, 400}) 

59 if resp.status_code == 400: 

60 return None 

61 payload = self.parse_json_response(resp, path) 

62 return self.parse_model(ArchiveMetadata, payload, path) 

63 

64 def get_untagged_archives(self) -> list[str]: 

65 """Get Archives that don't have any tags recorded. 

66 

67 This follows the same rules as the Batch Tagging filter and will 

68 include Archives that have parody:, date_added:, series: or artist: 

69 tags. 

70 

71 Returns: 

72 list[str]: IDs of the Archives that have no tags recorded. 

73 

74 Raises: 

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

76 APIResponseDecodeError: If the response body is not a list. 

77 """ 

78 path = "/api/archives/untagged" 

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

80 if not isinstance(payload, list): 

81 raise APIResponseDecodeError(self._to_url(path), "response is not a list") 

82 return cast(list[str], payload) 

83 

84 def get_archive_metadata(self, id: str) -> ArchiveMetadata | None: 

85 """Get Metadata (title, tags) for a given Archive. 

86 

87 Args: 

88 id: ID of the Archive to process. 

89 

90 Returns: 

91 ArchiveMetadata | None: Metadata of the archive, or None when the 

92 server answers with 400. 

93 

94 Raises: 

95 APIHttpError: Any status code other than 200 and 400. 

96 

97 Note: 

98 The 400 response, sent when no archive ID was given, is turned into 

99 None instead of raising. 

100 """ 

101 path = f"/api/archives/{id}/metadata" 

102 resp = self.request("GET", path, expected_statuses={200, 400}) 

103 if resp.status_code == 400: 

104 return None 

105 payload = self.parse_json_response(resp, path) 

106 return self.parse_model(ArchiveMetadata, payload, path) 

107 

108 def get_archive_categories(self, id: str) -> list[CategoryMetadata]: 

109 """Get all the Categories which currently refer to this Archive ID. 

110 

111 Args: 

112 id: ID of the Archive to process. 

113 

114 Returns: 

115 list[CategoryMetadata]: Metadata of every Category referring to 

116 this Archive. 

117 

118 Raises: 

119 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

120 status code. 

121 APIResponseDecodeError: If the response has no ``categories`` list, 

122 or if an item does not match ``CategoryMetadata``. 

123 """ 

124 path = f"/api/archives/{id}/categories" 

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

126 clist = payload.get("categories") 

127 if not isinstance(clist, list): 

128 raise APIResponseDecodeError(self._to_url(path), "missing categories list") 

129 return [ 

130 self.parse_model(CategoryMetadata, c, path) for c in cast(list[Any], clist) 

131 ] 

132 

133 def get_archive_tankoubons(self, id: str) -> list[str]: 

134 """Get all the Tankoubons which currently refer to this Archive ID. 

135 

136 Tankoubon (単行本) is the Japanese term for a bound volume. 

137 

138 Args: 

139 id: ID of the Archive to process. 

140 

141 Returns: 

142 list[str]: IDs of the Tankoubons referring to this Archive. 

143 

144 Raises: 

145 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

146 status code. 

147 APIResponseDecodeError: If the response has no ``tankoubons`` list. 

148 """ 

149 path = f"/api/archives/{id}/tankoubons" 

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

151 tankoubons = payload.get("tankoubons") 

152 if not isinstance(tankoubons, list): 

153 raise APIResponseDecodeError(self._to_url(path), "missing tankoubons list") 

154 return cast(list[str], tankoubons) 

155 

156 def get_archive_thumbnail( 

157 self, id: str, page: int = 1, no_fallback: bool | None = None 

158 ) -> Response: 

159 """Get a Thumbnail image for a given Archive. 

160 

161 This endpoint will return a placeholder image if it doesn't already 

162 exist. If you want to queue generation of the thumbnail in the 

163 background, use the ``no_fallback`` parameter: you will get a 

164 background job ID instead of the placeholder. 

165 

166 Args: 

167 id: ID of the Archive to process. 

168 page: Specify which page you want to get a thumbnail for. Defaults 

169 to 1, the cover. 

170 no_fallback: Disables the placeholder image, queues the thumbnail 

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

172 does nothing if the image already exists. (You will get the 

173 image with code 200 no matter what) Defaults to None. 

174 

175 Returns: 

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

177 code 200 or the job JSON with code 202. 

178 

179 Raises: 

180 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

181 status code. 

182 

183 Note: 

184 A queued extraction returns a Minion job ID; use 

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

186 """ 

187 no_fallback_value = None 

188 if no_fallback is not None: 

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

190 

191 return self.request( 

192 "GET", 

193 f"/api/archives/{id}/thumbnail", 

194 params={"page": page, "no_fallback": no_fallback_value}, 

195 ) 

196 

197 def queue_extraction_of_page_thumbnails( 

198 self, id: str, force: bool = False 

199 ) -> MinionJobResponse: 

200 """Create thumbnails for every page of a given Archive. 

201 

202 This endpoint will queue generation of the thumbnails in the 

203 background. 

204 

205 If all thumbnails are detected as already existing, the call will 

206 return HTTP code 200. 

207 

208 This endpoint can be called multiple times -- If a thumbnailing job is 

209 already in progress for the given ID, it'll just give you the ID for 

210 that ongoing job. 

211 

212 Args: 

213 id: ID of the Archive to process. 

214 force: Whether to force regeneration of all thumbnails even if they 

215 already exist. Defaults to False. 

216 

217 Returns: 

218 MinionJobResponse: Result of the operation, with the ID of the 

219 queued or ongoing Minion job in ``job``. 

220 

221 Raises: 

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

223 does not match ``MinionJobResponse``. 

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

225 

226 Note: 

227 The ``job`` field is None when all thumbnails already exist and the 

228 server answers with code 200 instead of queueing a job. A 400 

229 response is returned in the operation result, with ``success`` set 

230 to 0, instead of raising. 

231 """ 

232 return self.request_operation( 

233 "POST", 

234 f"/api/archives/{id}/files/thumbnails", 

235 model=MinionJobResponse, 

236 params={"force": force}, 

237 ) 

238 

239 def download_archive(self, id: str) -> Response: 

240 """Download an Archive from the server. 

241 

242 Args: 

243 id: ID of the Archive to download. 

244 

245 Returns: 

246 Response: Response of the server carrying the archive file. 

247 

248 Raises: 

249 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

250 status code. 

251 """ 

252 return self.request("GET", f"/api/archives/{id}/download") 

253 

254 def extract_archive(self, id: str, force: bool = False) -> dict[Any, Any]: 

255 """Get a list of URLs pointing to the images contained in an archive. 

256 

257 If necessary, this endpoint also launches a background Minion job to 

258 extract the archive so it is ready for reading. 

259 

260 Args: 

261 id: ID of the Archive to process. 

262 force: Force a full background re-extraction of the Archive. 

263 Existing cached files might still be used in subsequent 

264 ``/api/archives/:id/page`` calls until the Archive is fully 

265 re-extracted. Defaults to False. 

266 

267 Returns: 

268 dict: Decoded response, with the page URLs in ``pages`` and the ID 

269 of the background extract job in ``job``. 

270 

271 Raises: 

272 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

273 status code. 

274 """ 

275 return self.request_json( 

276 "GET", f"/api/archives/{id}/files", params={"force": force} 

277 ) 

278 

279 def add_archive_toc(self, id: str, page: int, title: str) -> OperationResponse: 

280 """Add an entry to the Table of Contents of a given Archive. 

281 

282 The ToC is stored as a JSON-encoded key-value array mapping a page to a 

283 title for the chapter/section starting at that page. 

284 

285 Args: 

286 id: ID of the Archive to process. 

287 page: Page number where the chapter/section starts. 

288 title: Title of the chapter/section. 

289 

290 Returns: 

291 OperationResponse: Result of the operation. 

292 

293 Raises: 

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

295 does not match ``OperationResponse``. 

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

297 

298 Note: 

299 Failure responses such as 400 (error response) or 423 (locked 

300 resource) are returned in the operation result, with ``success`` 

301 set to 0, instead of raising. 

302 """ 

303 return self.request_operation( 

304 "PUT", f"/api/archives/{id}/toc", params={"page": page, "title": title} 

305 ) 

306 

307 def delete_archive_toc(self, id: str, page: int) -> OperationResponse: 

308 """Delete an entry from the Table of Contents of a given Archive. 

309 

310 Args: 

311 id: ID of the Archive to process. 

312 page: Page number of the chapter/section to delete. 

313 

314 Returns: 

315 OperationResponse: Result of the operation. 

316 

317 Raises: 

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

319 does not match ``OperationResponse``. 

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

321 

322 Note: 

323 Failure responses such as 400 (error response) or 423 (locked 

324 resource) are returned in the operation result, with ``success`` 

325 set to 0, instead of raising. 

326 """ 

327 return self.request_operation( 

328 "DELETE", f"/api/archives/{id}/toc", params={"page": page} 

329 ) 

330 

331 def get_archive_page(self, id: str, path: str) -> Response: 

332 """Get an archive page. 

333 

334 This call is mainly used alongside ``/api/archives/files``. 

335 

336 Args: 

337 id: ID of the Archive to download. 

338 path: Path to the image in the extracted archive files. 

339 

340 Returns: 

341 Response: Response of the server carrying the image. 

342 

343 Raises: 

344 APIHttpError: 400 if no archive ID was given, or any other non-2xx 

345 status code. 

346 """ 

347 return self.request("GET", f"/api/archives/{id}/page", params={"path": path}) 

348 

349 def set_archive_new_flag(self, id: str) -> OperationResponse: 

350 """Set or restore the "New!" flag on an archive. 

351 

352 Args: 

353 id: ID of the Archive to process. 

354 

355 Returns: 

356 OperationResponse: Result of the operation. 

357 

358 Raises: 

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

360 does not match ``OperationResponse``. 

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

362 

363 Note: 

364 Failure responses such as 400 (error response) or 423 (locked 

365 resource) are returned in the operation result, with ``success`` 

366 set to 0, instead of raising. 

367 """ 

368 return self.request_operation("PUT", f"/api/archives/{id}/isnew") 

369 

370 def clear_archive_new_flag(self, id: str) -> OperationResponse: 

371 """Clear the "New!" flag on an archive. 

372 

373 Args: 

374 id: ID of the Archive to process. 

375 

376 Returns: 

377 OperationResponse: Result of the operation. 

378 

379 Raises: 

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

381 does not match ``OperationResponse``. 

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

383 

384 Note: 

385 Failure responses such as 400 (error response) or 423 (locked 

386 resource) are returned in the operation result, with ``success`` 

387 set to 0, instead of raising. 

388 """ 

389 return self.request_operation("DELETE", f"/api/archives/{id}/isnew") 

390 

391 def update_reading_progression(self, id: str, page: int) -> OperationResponse: 

392 """Tell the server which page of this Archive you are currently reading. 

393 

394 This endpoint will also update the date this Archive was last read, 

395 using the current server timestamp. 

396 

397 You should call this endpoint only when you're sure the user is 

398 currently reading the page you present. Don't use it when preloading 

399 images off the server. 

400 

401 Whether to make reading progression regressible or not is up to the 

402 client. (The web client will reduce progression if the user starts 

403 reading previous pages) 

404 

405 Consider however removing the "New!" flag from an archive when you 

406 start updating its progress - The web client won't display any reading 

407 progression if the new flag is still set. 

408 

409 Args: 

410 id: ID of the Archive to process. 

411 page: Current page to update the reading progress to. Must be a 

412 positive integer, and inferior or equal to the total page 

413 number of the archive. 

414 

415 Returns: 

416 OperationResponse: Result of the operation. 

417 

418 Raises: 

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

420 does not match ``OperationResponse``. 

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

422 

423 Note: 

424 Failure responses such as 400 (error response), 401 (authentication 

425 required) or 423 (locked resource) are returned in the operation 

426 result, with ``success`` set to 0, instead of raising. 

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

428 this API call returns an error. Check with ``/api/info`` whether 

429 the server tracks reading progression before calling this endpoint. 

430 """ 

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

432 

433 def upload_archive( 

434 self, 

435 archive: str | tuple[str, bytes], 

436 title: str | None = None, 

437 tags: str | None = None, 

438 summary: str | None = None, 

439 category_id: str | None = None, 

440 file_checksum: str | None = None, 

441 ) -> OperationResponse: 

442 """Upload an Archive to the server. 

443 

444 If a SHA1 checksum of the Archive is included, the server will perform 

445 an optional in-transit, file integrity validation, and reject the 

446 upload if the server-side checksum does not match. 

447 

448 Args: 

449 archive: str type for path of the archive file to upload, or a tuple 

450 made up of a filename and the file content in bytes. 

451 title: Title of the Archive. Defaults to None. 

452 tags: Set of tags you want to insert in the database alongside the 

453 archive. Defaults to None. 

454 summary: Summary of the Archive. Defaults to None. 

455 category_id: Category ID you'd want the archive to be added to. 

456 Defaults to None. 

457 file_checksum: SHA1 checksum of the archive for in-transit 

458 validation. Defaults to None. 

459 

460 Returns: 

461 OperationResponse: Result of the operation, with the ID of the 

462 uploaded Archive in the extra ``id`` field. 

463 

464 Raises: 

465 FileNotFoundError: If ``archive`` points to an no-existing file. 

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

467 does not match ``OperationResponse``. 

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

469 

470 Note: 

471 Uploading an archive that already exists is reported with a 409 

472 response, whose body carries the reason in the ``error`` field. 

473 Other failure responses, such as 415 (unsupported file), 417 

474 (checksum mismatch) or 422 (unprocessable entity), are returned in 

475 the operation result, with ``success`` set to 0, instead of 

476 raising. 

477 """ 

478 

479 if isinstance(archive, str): 

480 if not isfile(archive): 

481 raise FileNotFoundError(f"File {archive} not found") 

482 filename = os.path.basename(archive) 

483 with open(archive, "rb") as f: 

484 file_content = f.read() 

485 else: 

486 filename = archive[0] 

487 file_content = archive[1] 

488 

489 return self.request_operation( 

490 "PUT", 

491 "/api/archives/upload", 

492 files={ 

493 "file": ( 

494 filename, 

495 file_content, 

496 "application/octet-stream", 

497 ) 

498 }, 

499 data={ 

500 "title": title, 

501 "tags": tags, 

502 "summary": summary, 

503 "category_id": category_id, 

504 "file_checksum": file_checksum, 

505 }, 

506 ) 

507 

508 def update_thumbnail(self, id: str, page: int = 1) -> OperationResponse: 

509 """Update the cover thumbnail for the given Archive. 

510 

511 You can specify a page number to use as the thumbnail, or you can use 

512 the default thumbnail. 

513 

514 Args: 

515 id: ID of the Archive to process. 

516 page: Page you want to make the thumbnail out of. Defaults to 1. 

517 

518 Returns: 

519 OperationResponse: Result of the operation, with the path of the 

520 new thumbnail in the extra ``new_thumbnail`` field. 

521 

522 Raises: 

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

524 does not match ``OperationResponse``. 

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

526 

527 Note: 

528 A 400 response is returned in the operation result, with ``success`` 

529 set to 0, instead of raising. 

530 """ 

531 return self.request_operation( 

532 "PUT", f"/api/archives/{id}/thumbnail", params={"page": page} 

533 ) 

534 

535 def update_archive_metadata( 

536 self, 

537 id: str, 

538 archive: ArchiveMetadata | None = None, 

539 *, 

540 title: str | None = None, 

541 tags: str | None = None, 

542 summary: str | None = None, 

543 ) -> OperationResponse: 

544 """Update tags, title and summary for the given Archive. 

545 

546 Data supplied to the server through this method will overwrite the 

547 previous data. 

548 

549 Args: 

550 id: ID of the Archive to process. 

551 archive: Optional backward-compatible metadata object, used to fill 

552 ``title``, ``tags`` or ``summary`` when they are omitted. 

553 Defaults to None. 

554 title: Archive title to set. If omitted and ``archive`` is 

555 provided, ``archive.title`` is used. Defaults to None. 

556 tags: Archive tags string to set. If omitted and ``archive`` is 

557 provided, ``archive.tags`` is used. Defaults to None. 

558 summary: Archive summary to set. If omitted and ``archive`` is 

559 provided, ``archive.summary`` is used. Defaults to None. 

560 

561 Returns: 

562 OperationResponse: Result of the operation. 

563 

564 Raises: 

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

566 does not match ``OperationResponse``. 

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

568 

569 Note: 

570 Failure responses such as 400 (error response) or 423 (locked 

571 resource) are returned in the operation result, with ``success`` 

572 set to 0, instead of raising. 

573 """ 

574 if archive is not None: 

575 if title is None: 

576 title = archive.title 

577 if tags is None: 

578 tags = archive.tags 

579 if summary is None: 

580 summary = archive.summary 

581 

582 return self.request_operation( 

583 "PUT", 

584 f"/api/archives/{id}/metadata", 

585 params={"title": title, "tags": tags, "summary": summary}, 

586 ) 

587 

588 def delete_archive(self, id: str) -> OperationResponse: 

589 """Delete both the archive metadata and the file stored on the server. 

590 

591 Please ask your user for confirmation before invoking this endpoint. 

592 

593 Args: 

594 id: ID of the Archive to process. 

595 

596 Returns: 

597 OperationResponse: Result of the operation. 

598 

599 Raises: 

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

601 does not match ``OperationResponse``. 

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

603 

604 Note: 

605 Failure responses such as 400 (error response) or 423 (locked 

606 resource) are returned in the operation result, with ``success`` 

607 set to 0, instead of raising. 

608 """ 

609 return self.request_operation("DELETE", f"/api/archives/{id}")