Coverage for src/lanraragi_api/api/archive.py: 88%
93 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 23:20 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 23:20 +0000
1import os
2from os.path import isfile
3from typing import Any, cast
5from requests import Response
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
17class ArchiveAPI(BaseAPICall):
18 """Everything dealing with Archives.
20 Shared request and error behavior is documented on ``BaseAPICall``.
21 """
23 def get_all_archives(self) -> list[ArchiveMetadata]:
24 """Return a list of all Archives in the database.
26 This doesn't include Tankoubons by design. You can use the IDs of this
27 JSON with the other endpoints.
29 Returns:
30 list[ArchiveMetadata]: Metadata of every Archive in the database.
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)
39 def get_archive(self, id: str) -> ArchiveMetadata | None:
40 """Get Metadata (title, tags) for a given Archive.
42 Args:
43 id: ID of the Archive to process.
45 Returns:
46 ArchiveMetadata | None: Metadata of the archive, or None when the
47 server answers with 400.
49 Raises:
50 APIHttpError: Any status code other than 200 and 400.
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)
64 def get_untagged_archives(self) -> list[str]:
65 """Get Archives that don't have any tags recorded.
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.
71 Returns:
72 list[str]: IDs of the Archives that have no tags recorded.
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)
84 def get_archive_metadata(self, id: str) -> ArchiveMetadata | None:
85 """Get Metadata (title, tags) for a given Archive.
87 Args:
88 id: ID of the Archive to process.
90 Returns:
91 ArchiveMetadata | None: Metadata of the archive, or None when the
92 server answers with 400.
94 Raises:
95 APIHttpError: Any status code other than 200 and 400.
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)
108 def get_archive_categories(self, id: str) -> list[CategoryMetadata]:
109 """Get all the Categories which currently refer to this Archive ID.
111 Args:
112 id: ID of the Archive to process.
114 Returns:
115 list[CategoryMetadata]: Metadata of every Category referring to
116 this Archive.
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 ]
133 def get_archive_tankoubons(self, id: str) -> list[str]:
134 """Get all the Tankoubons which currently refer to this Archive ID.
136 Tankoubon (単行本) is the Japanese term for a bound volume.
138 Args:
139 id: ID of the Archive to process.
141 Returns:
142 list[str]: IDs of the Tankoubons referring to this Archive.
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)
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.
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.
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.
175 Returns:
176 Response: Response of the server, either the thumbnail bytes with
177 code 200 or the job JSON with code 202.
179 Raises:
180 APIHttpError: 400 if no archive ID was given, or any other non-2xx
181 status code.
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"
191 return self.request(
192 "GET",
193 f"/api/archives/{id}/thumbnail",
194 params={"page": page, "no_fallback": no_fallback_value},
195 )
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.
202 This endpoint will queue generation of the thumbnails in the
203 background.
205 If all thumbnails are detected as already existing, the call will
206 return HTTP code 200.
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.
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.
217 Returns:
218 MinionJobResponse: Result of the operation, with the ID of the
219 queued or ongoing Minion job in ``job``.
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.
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 )
239 def download_archive(self, id: str) -> Response:
240 """Download an Archive from the server.
242 Args:
243 id: ID of the Archive to download.
245 Returns:
246 Response: Response of the server carrying the archive file.
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")
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.
257 If necessary, this endpoint also launches a background Minion job to
258 extract the archive so it is ready for reading.
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.
267 Returns:
268 dict: Decoded response, with the page URLs in ``pages`` and the ID
269 of the background extract job in ``job``.
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 )
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.
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.
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.
290 Returns:
291 OperationResponse: Result of the operation.
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.
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 )
307 def delete_archive_toc(self, id: str, page: int) -> OperationResponse:
308 """Delete an entry from the Table of Contents of a given Archive.
310 Args:
311 id: ID of the Archive to process.
312 page: Page number of the chapter/section to delete.
314 Returns:
315 OperationResponse: Result of the operation.
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.
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 )
331 def get_archive_page(self, id: str, path: str) -> Response:
332 """Get an archive page.
334 This call is mainly used alongside ``/api/archives/files``.
336 Args:
337 id: ID of the Archive to download.
338 path: Path to the image in the extracted archive files.
340 Returns:
341 Response: Response of the server carrying the image.
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})
349 def set_archive_new_flag(self, id: str) -> OperationResponse:
350 """Set or restore the "New!" flag on an archive.
352 Args:
353 id: ID of the Archive to process.
355 Returns:
356 OperationResponse: Result of the operation.
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.
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")
370 def clear_archive_new_flag(self, id: str) -> OperationResponse:
371 """Clear the "New!" flag on an archive.
373 Args:
374 id: ID of the Archive to process.
376 Returns:
377 OperationResponse: Result of the operation.
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.
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")
391 def update_reading_progression(self, id: str, page: int) -> OperationResponse:
392 """Tell the server which page of this Archive you are currently reading.
394 This endpoint will also update the date this Archive was last read,
395 using the current server timestamp.
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.
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)
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.
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.
415 Returns:
416 OperationResponse: Result of the operation.
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.
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}")
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.
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.
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.
460 Returns:
461 OperationResponse: Result of the operation, with the ID of the
462 uploaded Archive in the extra ``id`` field.
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.
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 """
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]
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 )
508 def update_thumbnail(self, id: str, page: int = 1) -> OperationResponse:
509 """Update the cover thumbnail for the given Archive.
511 You can specify a page number to use as the thumbnail, or you can use
512 the default thumbnail.
514 Args:
515 id: ID of the Archive to process.
516 page: Page you want to make the thumbnail out of. Defaults to 1.
518 Returns:
519 OperationResponse: Result of the operation, with the path of the
520 new thumbnail in the extra ``new_thumbnail`` field.
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.
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 )
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.
546 Data supplied to the server through this method will overwrite the
547 previous data.
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.
561 Returns:
562 OperationResponse: Result of the operation.
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.
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
582 return self.request_operation(
583 "PUT",
584 f"/api/archives/{id}/metadata",
585 params={"title": title, "tags": tags, "summary": summary},
586 )
588 def delete_archive(self, id: str) -> OperationResponse:
589 """Delete both the archive metadata and the file stored on the server.
591 Please ask your user for confirmation before invoking this endpoint.
593 Args:
594 id: ID of the Archive to process.
596 Returns:
597 OperationResponse: Result of the operation.
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.
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}")