lanraragi_api 0.9.70.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.3
2
+ Name: lanraragi_api
3
+ Version: 0.9.70.1
4
+ Summary: A Python library for LANraragi API.
5
+ Author: Gustav
6
+ Author-email: Gustav <gustaavv.git@yahoo.com>
7
+ Requires-Dist: pydantic==2.13.0
8
+ Requires-Dist: requests==2.33.1
9
+ Requires-Python: >=3.10
10
+ Project-URL: repository, https://github.com/gustaavv/lanraragi-api
11
+ Description-Content-Type: text/markdown
12
+
13
+ # lanraragi-api
14
+
15
+ A Python library for [LANraragi](https://github.com/Difegue/LANraragi) API.
16
+
17
+ > Many thanks to the author of this wonderful manga server.
18
+
19
+
20
+
21
+ ## Quick start
22
+
23
+ Install this package:
24
+
25
+ ```shell
26
+ pip install lanraragi_api
27
+ ```
28
+
29
+ Get metadata of a random archive:
30
+
31
+ > See [demo.py](demo.py)
32
+
33
+ ```python
34
+ from lanraragi_api import LANraragiAPI
35
+ from lanraragi_api.base.archive import ArchiveMetadata
36
+
37
+ apikey = "your-key"
38
+ server = "http://127.0.0.1:3000"
39
+ api = LANraragiAPI(server, key=apikey)
40
+
41
+ archives: list[ArchiveMetadata] = api.search.get_random_archives()
42
+ print(archives[0])
43
+ ```
44
+
45
+
46
+
47
+ ## How to use?
48
+
49
+ All the APIs in the `lanraragi_api.base` package are
50
+ from [the official LANraragi document](https://sugoi.gitbook.io/lanraragi/api-documentation/getting-started), which you will be using in most times.
51
+
52
+ Functions in the `lanraragi_api.enhanced` package are built on the base APIs, offering useful functionalities.
53
+
54
+ - `server_side.py` contains server-side functions. The code is the same to that of LANraragi, only translated from Perl to Python.
55
+ - `script.py` contains functions for operation and management.
56
+
57
+
58
+
59
+
60
+
61
+ ## Release versions
62
+
63
+ Every release of lanraragi-api is made only for the corresponding release of LANraragi. So you should choose the correct lanraragi-api version based on the server version.
64
+
65
+ | LANraragi | lanraragi-api |
66
+ | ---------- | ---------------------------------------- |
67
+ | `v.0.9.0` | `0.9.0.0`, `0.9.0.1`, ... , `0.9.0.x` |
68
+ | `v.0.9.40` | `0.9.40.0`, `0.9.40.1`, ... , `0.9.40.y` |
69
+
70
+ In order to make it simple, the first three version numbers are always the same, while the last version number of lanraragi-api serves as patches (just choose the latest one).
71
+
72
+
73
+
74
+ ## Development
75
+
76
+ Python version: 3.10
77
+
78
+ Code formatter: default setting of [VS Code's Black Formatter extension](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter)
@@ -0,0 +1,66 @@
1
+ # lanraragi-api
2
+
3
+ A Python library for [LANraragi](https://github.com/Difegue/LANraragi) API.
4
+
5
+ > Many thanks to the author of this wonderful manga server.
6
+
7
+
8
+
9
+ ## Quick start
10
+
11
+ Install this package:
12
+
13
+ ```shell
14
+ pip install lanraragi_api
15
+ ```
16
+
17
+ Get metadata of a random archive:
18
+
19
+ > See [demo.py](demo.py)
20
+
21
+ ```python
22
+ from lanraragi_api import LANraragiAPI
23
+ from lanraragi_api.base.archive import ArchiveMetadata
24
+
25
+ apikey = "your-key"
26
+ server = "http://127.0.0.1:3000"
27
+ api = LANraragiAPI(server, key=apikey)
28
+
29
+ archives: list[ArchiveMetadata] = api.search.get_random_archives()
30
+ print(archives[0])
31
+ ```
32
+
33
+
34
+
35
+ ## How to use?
36
+
37
+ All the APIs in the `lanraragi_api.base` package are
38
+ from [the official LANraragi document](https://sugoi.gitbook.io/lanraragi/api-documentation/getting-started), which you will be using in most times.
39
+
40
+ Functions in the `lanraragi_api.enhanced` package are built on the base APIs, offering useful functionalities.
41
+
42
+ - `server_side.py` contains server-side functions. The code is the same to that of LANraragi, only translated from Perl to Python.
43
+ - `script.py` contains functions for operation and management.
44
+
45
+
46
+
47
+
48
+
49
+ ## Release versions
50
+
51
+ Every release of lanraragi-api is made only for the corresponding release of LANraragi. So you should choose the correct lanraragi-api version based on the server version.
52
+
53
+ | LANraragi | lanraragi-api |
54
+ | ---------- | ---------------------------------------- |
55
+ | `v.0.9.0` | `0.9.0.0`, `0.9.0.1`, ... , `0.9.0.x` |
56
+ | `v.0.9.40` | `0.9.40.0`, `0.9.40.1`, ... , `0.9.40.y` |
57
+
58
+ In order to make it simple, the first three version numbers are always the same, while the last version number of lanraragi-api serves as patches (just choose the latest one).
59
+
60
+
61
+
62
+ ## Development
63
+
64
+ Python version: 3.10
65
+
66
+ Code formatter: default setting of [VS Code's Black Formatter extension](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter)
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "lanraragi_api"
3
+ version = "0.9.70.1"
4
+ description = "A Python library for LANraragi API."
5
+ readme = "README.md"
6
+ authors = [{ name = "Gustav", email = "gustaavv.git@yahoo.com" }]
7
+ requires-python = ">=3.10"
8
+ dependencies = ["pydantic==2.13.0", "requests==2.33.1"]
9
+ urls = { repository = "https://github.com/gustaavv/lanraragi-api" }
10
+
11
+ [build-system]
12
+ requires = ["uv_build>=0.11.7,<0.12.0"]
13
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ from lanraragi_api.lanraragi import LANraragiAPI
@@ -0,0 +1,28 @@
1
+ from lanraragi_api.base.archive import ArchiveAPI, ArchiveMetadata
2
+ from lanraragi_api.base.base import (
3
+ APIError,
4
+ APIHttpError,
5
+ APIOperationError,
6
+ APIRequestError,
7
+ APIResponseDecodeError,
8
+ Auth,
9
+ MinionJobResponse,
10
+ OperationResponse,
11
+ )
12
+ from lanraragi_api.base.category import CategoryAPI, CategoryMetadata
13
+ from lanraragi_api.base.database import (
14
+ BackupArchiveMetadata,
15
+ BackupCategoryMetadata,
16
+ DatabaseAPI,
17
+ DatabaseBackup,
18
+ )
19
+ from lanraragi_api.base.minion import MinionAPI
20
+ from lanraragi_api.base.misc import MiscAPI
21
+ from lanraragi_api.base.search import SearchAPI
22
+ from lanraragi_api.base.shinobu import ShinobuAPI
23
+ from lanraragi_api.base.tankoubon import (
24
+ TankoubonAPI,
25
+ TankoubonDetailResponse,
26
+ TankoubonListResponse,
27
+ TankoubonMetadata,
28
+ )
@@ -0,0 +1,442 @@
1
+ from os.path import isfile
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, Field
5
+ from requests import Response
6
+
7
+ from lanraragi_api.base.base import (
8
+ APIResponseDecodeError,
9
+ BaseAPICall,
10
+ MinionJobResponse,
11
+ OperationResponse,
12
+ )
13
+ from lanraragi_api.base.category import CategoryMetadata
14
+
15
+ ARCHIVE_TAG_VALUES_SET = "ONLY_VALUES"
16
+
17
+
18
+ class ArchiveMetadata(BaseModel):
19
+ arcid: str = Field(...)
20
+ extension: str = Field(...)
21
+ filename: str = Field(...)
22
+ isnew: bool | str | None = Field(default=None)
23
+ lastreadtime: int = Field(...)
24
+ pagecount: int = Field(...)
25
+ progress: int = Field(...)
26
+ size: int = Field(...)
27
+ summary: str | None = Field(default=None)
28
+ toc: list[dict] | None = Field(default=None)
29
+
30
+ # k1:v1, k2:v21, k2:v22, v3, v4
31
+ # allow duplicate keys, only values
32
+ tags: str = Field(...)
33
+ title: str = Field(...)
34
+
35
+ def __tags_to_dict(self) -> dict[str, list[str]]:
36
+ tags = self.tags.split(",")
37
+ ans = {}
38
+ for t in tags:
39
+ if t == "":
40
+ continue
41
+ t = t.strip()
42
+ if ":" in t:
43
+ kv = t.split(":")
44
+ k = kv[0]
45
+ v = kv[1]
46
+ if k not in ans:
47
+ ans[k] = []
48
+ ans[k].append(v)
49
+ else:
50
+ k = ARCHIVE_TAG_VALUES_SET
51
+ if k not in ans:
52
+ ans[k] = []
53
+ ans[k].append(t)
54
+ return ans
55
+
56
+ def __dict_to_tags(self, json: dict[str, list[str]]):
57
+ """
58
+ The function will modify the object
59
+ """
60
+ tags = ""
61
+ modified: bool = False
62
+ for k in json:
63
+ for v in json[k]:
64
+ modified = True
65
+ if k == ARCHIVE_TAG_VALUES_SET:
66
+ tags += f"{v},"
67
+ else:
68
+ tags += f"{k}:{v},"
69
+ if modified:
70
+ tags = tags[:-1]
71
+ self.tags = tags
72
+
73
+ def get_artists(self) -> list[str]:
74
+ return self.__tags_to_dict()["artist"]
75
+
76
+ def set_artists(self, artists: list[str]):
77
+ json = self.__tags_to_dict()
78
+ json["artist"] = artists
79
+ self.__dict_to_tags(json)
80
+
81
+ def remove_artists(self):
82
+ json = self.__tags_to_dict()
83
+ json["artist"] = []
84
+ self.__dict_to_tags(json)
85
+
86
+ def has_artists(self) -> bool:
87
+ return "artist" in self.tags
88
+
89
+
90
+ class ArchiveAPI(BaseAPICall):
91
+ """
92
+ Everything dealing with Archives.
93
+ """
94
+
95
+ def get_all_archives(self) -> list[ArchiveMetadata]:
96
+ """
97
+ Get the Archive Index in JSON form. You can use the IDs of this JSON
98
+ with the other endpoints.
99
+ :return: list of archives
100
+ """
101
+ return self.request_model_list("GET", "/api/archives", ArchiveMetadata)
102
+
103
+ def get_archive(self, id: str) -> Optional[ArchiveMetadata]:
104
+ """
105
+ Get Metadata (title, tags) for a given Archive using deprecated endpoint.
106
+ :param id: ID of the Archive to process.
107
+ :return: archive
108
+ """
109
+ path = f"/api/archives/{id}"
110
+ resp = self.request("GET", path, expected_statuses={200, 400})
111
+ if resp.status_code == 400:
112
+ return None
113
+ payload = self.parse_json_response(resp, path)
114
+ return self.parse_model(ArchiveMetadata, payload, path)
115
+
116
+ def get_untagged_archives(self) -> list[str]:
117
+ """
118
+ Get archives that don't have any tags recorded. This follows the same
119
+ rules as the Batch Tagging filter and will include archives that have
120
+ parody:, date_added:, series: or artist: tags.
121
+ :return: list of archive IDs
122
+ """
123
+ path = "/api/archives/untagged"
124
+ payload = self.request_json("GET", path)
125
+ if not isinstance(payload, list):
126
+ raise APIResponseDecodeError(self._to_url(path), "response is not a list")
127
+ return payload
128
+
129
+ def get_archive_metadata(self, id: str) -> Optional[ArchiveMetadata]:
130
+ """
131
+ Get Metadata (title, tags) for a given Archive.
132
+ :param id: ID of the Archive to process.
133
+ :return: archive
134
+ """
135
+ path = f"/api/archives/{id}/metadata"
136
+ resp = self.request("GET", path, expected_statuses={200, 400})
137
+ if resp.status_code == 400:
138
+ return None
139
+ payload = self.parse_json_response(resp, path)
140
+ return self.parse_model(ArchiveMetadata, payload, path)
141
+
142
+ def get_archive_categories(self, id: str) -> list[CategoryMetadata]:
143
+ """
144
+ Get all the Categories which currently refer to this Archive ID.
145
+ :param id: ID of the Archive to process.
146
+ :return: list of category metadata
147
+ """
148
+ path = f"/api/archives/{id}/categories"
149
+ payload = self.request_json("GET", path)
150
+ clist = payload.get("categories")
151
+ if not isinstance(clist, list):
152
+ raise APIResponseDecodeError(self._to_url(path), "missing categories list")
153
+ return [self.parse_model(CategoryMetadata, c, path) for c in clist]
154
+
155
+ def get_archive_tankoubons(self, id: str) -> list[str]:
156
+ """
157
+ Get all the Tankoubons which currently refer to this Archive ID.
158
+
159
+ Tankoubon: 単行本
160
+ :param id: ID of the Archive to process.
161
+ :return: list of tankoubon ids
162
+ """
163
+ path = f"/api/archives/{id}/tankoubons"
164
+ payload = self.request_json("GET", path)
165
+ tankoubons = payload.get("tankoubons")
166
+ if not isinstance(tankoubons, list):
167
+ raise APIResponseDecodeError(self._to_url(path), "missing tankoubons list")
168
+ return tankoubons
169
+
170
+ def get_archive_thumbnail(
171
+ self, id: str, page: int = 1, no_fallback: bool | None = None
172
+ ) -> Response:
173
+ """
174
+ Get a Thumbnail image for a given Archive. This endpoint will return
175
+ a placeholder image if it doesn't already exist.
176
+
177
+ If you want to queue generation of the thumbnail in the background,
178
+ you can use the no_fallback query parameter. This will give you a
179
+ background job ID instead of the placeholder.
180
+
181
+ :param id: ID of the Archive to process.
182
+ :param page: Specify which page you want to get a thumbnail for.
183
+ Defaults to the cover, aka page 1.
184
+ :param no_fallback: Disables the placeholder image, queues the
185
+ thumbnail for extraction and returns a JSON with code 202. This
186
+ parameter does nothing if the image already exists. (You will get the
187
+ image with code 200 no matter what)
188
+ :return: the response object
189
+ """
190
+ no_fallback_value = None
191
+ if no_fallback is not None:
192
+ no_fallback_value = "true" if no_fallback else "false"
193
+
194
+ return self.request(
195
+ "GET",
196
+ f"/api/archives/{id}/thumbnail",
197
+ params={"page": page, "no_fallback": no_fallback_value},
198
+ )
199
+
200
+ def queue_extraction_of_page_thumbnails(
201
+ self, id: str, force: bool = False
202
+ ) -> MinionJobResponse:
203
+ """
204
+ Create thumbnails for every page of a given Archive. This endpoint will
205
+ queue generation of the thumbnails in the background.
206
+
207
+ If all thumbnails are detected as already existing, the call will
208
+ return HTTP code 200.
209
+
210
+ This endpoint can be called multiple times -- If a thumbnailing job is
211
+ already in progress for the given ID, it'll just give you the ID for
212
+ that ongoing job.
213
+ :param id: ID of the Archive to process.
214
+ :param force: Whether to force regeneration of all thumbnails even if
215
+ they already exist.
216
+ :return: operation result
217
+ """
218
+ return self.request_operation(
219
+ "POST",
220
+ f"/api/archives/{id}/files/thumbnails",
221
+ model=MinionJobResponse,
222
+ params={"force": force},
223
+ )
224
+
225
+ def download_archive(self, id: str) -> Response:
226
+ """
227
+ Download an Archive from the server.
228
+
229
+ :param id: ID of the Archive to download.
230
+ :return: the response object
231
+ """
232
+ return self.request("GET", f"/api/archives/{id}/download")
233
+
234
+ def extract_archive(self, id: str, force: bool = False) -> dict:
235
+ """
236
+ Get a list of URLs pointing to the images contained in an archive.
237
+ If necessary, this endpoint also launches a background Minion job to
238
+ extract the archive so it is ready for reading.
239
+
240
+ :param id: ID of the Archive to process.
241
+ :param force: Force a full background re-extraction of the Archive.
242
+ Existing cached files might still be used in subsequent
243
+ /api/archives/:id/page calls until the Archive is fully re-extracted.
244
+ :return: operation result
245
+ """
246
+ return self.request_json(
247
+ "GET", f"/api/archives/{id}/files", params={"force": force}
248
+ )
249
+
250
+ def add_archive_toc(self, id: str, page: int, title: str) -> OperationResponse:
251
+ """
252
+ Add a Table of Contents entry for an archive.
253
+ :param id: ID of the Archive to process.
254
+ :param page: Page number where the chapter starts.
255
+ :param title: Chapter title.
256
+ :return: operation result
257
+ """
258
+ return self.request_operation(
259
+ "PUT", f"/api/archives/{id}/toc", params={"page": page, "title": title}
260
+ )
261
+
262
+ def delete_archive_toc(self, id: str, page: int) -> OperationResponse:
263
+ """
264
+ Delete a Table of Contents entry for an archive.
265
+ :param id: ID of the Archive to process.
266
+ :param page: Page number of the chapter entry to remove.
267
+ :return: operation result
268
+ """
269
+ return self.request_operation(
270
+ "DELETE", f"/api/archives/{id}/toc", params={"page": page}
271
+ )
272
+
273
+ def get_archive_page(self, id: str, path: str) -> Response:
274
+ """
275
+ Get a specific image page from an archive.
276
+ :param id: ID of the Archive to process.
277
+ :param path: Path to the image in extracted archive files.
278
+ :return: the response object
279
+ """
280
+ return self.request("GET", f"/api/archives/{id}/page", params={"path": path})
281
+
282
+ def set_archive_new_flag(self, id: str) -> OperationResponse:
283
+ """
284
+ Sets/restores the "New!" flag on an archive.
285
+
286
+ :param id: ID of the Archive to process.
287
+ :return: operation result
288
+ """
289
+ return self.request_operation("PUT", f"/api/archives/{id}/isnew")
290
+
291
+ def clear_archive_new_flag(self, id: str) -> OperationResponse:
292
+ """
293
+ Clears the "New!" flag on an archive.
294
+
295
+ :param id: ID of the Archive to process.
296
+ :return: operation result
297
+ """
298
+ return self.request_operation("DELETE", f"/api/archives/{id}/isnew")
299
+
300
+ def update_reading_progression(self, id: str, page: int) -> OperationResponse:
301
+ """
302
+ Tell the server which page of this Archive you're currently
303
+ showing/reading, so that it updates its internal reading progression
304
+ accordingly.
305
+
306
+ This endpoint will also update the date this Archive was last read,
307
+ using the current server timestamp.
308
+
309
+ You should call this endpoint only when you're sure the user is
310
+ currently reading the page you present.
311
+
312
+ Don't use it when preloading images off the server.
313
+
314
+ Whether to make reading progression regressible or not is up to
315
+ the client. (The web client will reduce progression if the user
316
+ starts reading previous pages)
317
+
318
+ Consider however removing the "New!" flag from an archive when you
319
+ start updating its progress - The web client won't display any
320
+ reading progression if the new flag is still set.
321
+
322
+ ⚠ If the server is configured to use clientside progress tracking,
323
+ this API call will return an error!
324
+
325
+ Make sure to check using /api/info whether the server tracks reading
326
+ progression or not before calling this endpoint.
327
+ :param id: ID of the Archive to process
328
+ :param page: Current page to update the reading progress to. Must be
329
+ a positive integer, and inferior or equal to the total page number of
330
+ the archive.
331
+ :return: operation result
332
+ """
333
+ return self.request_operation("PUT", f"/api/archives/{id}/progress/{page}")
334
+
335
+ def upload_archive(
336
+ self,
337
+ archive_path: str,
338
+ title: str = None,
339
+ tags: str = None,
340
+ summary: str = None,
341
+ category_id: str = None,
342
+ file_checksum: str = None,
343
+ ) -> OperationResponse:
344
+ """
345
+ Upload an Archive to the server.
346
+
347
+ If a SHA1 checksum of the Archive is included, the server will perform
348
+ an optional in-transit, file integrity validation, and reject the
349
+ upload if the server-side checksum does not match.
350
+ :param archive_path: filepath of the archive
351
+ :param title: Title of the Archive.
352
+ :param tags: Set of tags you want to insert in the database alongside
353
+ the archive.
354
+ :param summary: summary
355
+ :param category_id: Category ID you'd want the archive to be added to.
356
+ :param file_checksum: SHA1 checksum of the archive for in-transit
357
+ validation.
358
+ :return: operation result
359
+ """
360
+ # deal with windows path separator
361
+ archive_path = archive_path.replace("\\", "/")
362
+
363
+ if not isfile(archive_path):
364
+ raise FileNotFoundError(f"File {archive_path} not found")
365
+
366
+ with open(archive_path, "rb") as archive_file:
367
+ return self.request_operation(
368
+ "PUT",
369
+ "/api/archives/upload",
370
+ files={
371
+ "file": (
372
+ archive_path.split("/")[-1],
373
+ archive_file,
374
+ "application/octet-stream",
375
+ )
376
+ },
377
+ data={
378
+ "title": title,
379
+ "tags": tags,
380
+ "summary": summary,
381
+ "category_id": category_id,
382
+ "file_checksum": file_checksum,
383
+ },
384
+ )
385
+
386
+ def update_thumbnail(self, id: str, page: int = 1) -> OperationResponse:
387
+ """
388
+ Update the cover thumbnail for the given Archive. You can specify a
389
+ page number to use as the thumbnail, or you can use the default
390
+ thumbnail.
391
+ :param id: ID of the Archive to process.
392
+ :param page: Page you want to make the thumbnail out of. Defaults to 1.
393
+ :return: operation result
394
+ """
395
+ return self.request_operation(
396
+ "PUT", f"/api/archives/{id}/thumbnail", params={"page": page}
397
+ )
398
+
399
+ def update_archive_metadata(
400
+ self,
401
+ id: str,
402
+ archive: ArchiveMetadata | None = None,
403
+ *,
404
+ title: str | None = None,
405
+ tags: str | None = None,
406
+ summary: str | None = None,
407
+ ) -> OperationResponse:
408
+ """
409
+ Update tags, title and summary for the given Archive.
410
+ :param id: ID of the Archive to process.
411
+ :param archive: Optional backward-compatible metadata object.
412
+ :param title: Archive title to set. If omitted and archive is provided,
413
+ uses archive.title.
414
+ :param tags: Archive tags string to set. If omitted and archive is
415
+ provided, uses archive.tags.
416
+ :param summary: Archive summary to set. If omitted and archive is
417
+ provided, uses archive.summary.
418
+ :return: operation result
419
+ """
420
+ if archive is not None:
421
+ if title is None:
422
+ title = archive.title
423
+ if tags is None:
424
+ tags = archive.tags
425
+ if summary is None:
426
+ summary = archive.summary
427
+
428
+ return self.request_operation(
429
+ "PUT",
430
+ f"/api/archives/{id}/metadata",
431
+ params={"title": title, "tags": tags, "summary": summary},
432
+ )
433
+
434
+ def delete_archive(self, id: str) -> OperationResponse:
435
+ """
436
+ Delete both the archive metadata and the file stored on the server.
437
+
438
+ 🙏 Please ask your user for confirmation before invoking this endpoint.
439
+ :param id: ID of the Archive to process.
440
+ :return: operation result
441
+ """
442
+ return self.request_operation("DELETE", f"/api/archives/{id}")