flget 0.1.0__py3-none-any.whl

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.
flget/__init__.py ADDED
File without changes
flget/config.py ADDED
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+ from typing import Any, cast
3
+
4
+ import platformdirs
5
+ from tomlkit import comment, document, dump, load, nl, table
6
+
7
+
8
+ class Config:
9
+ def __init__(self) -> None:
10
+ flget_config_dir = platformdirs.user_config_dir("flget", ensure_exists=True)
11
+ flget_config = Path(flget_config_dir) / "flget.toml"
12
+
13
+ self._flget_config = flget_config
14
+
15
+ self._libdir: Path | None = None
16
+ if flget_config.exists():
17
+ with flget_config.open() as config_file:
18
+ doc = load(config_file)
19
+ self._libdir = Path(str(cast("Any", doc)["flget"]["libdir"]))
20
+
21
+ def libdir(self) -> Path | None:
22
+ return self._libdir
23
+
24
+ def require_libdir(self) -> Path:
25
+ if self._libdir is None:
26
+ msg = "Library location is not set"
27
+ raise RuntimeError(msg)
28
+ return self._libdir
29
+
30
+ def set_libdir(self, path: Path) -> None:
31
+ self._libdir = path
32
+
33
+ def save(self) -> None:
34
+ doc = document()
35
+ doc.add(comment("flget/fltui configuration file."))
36
+ doc.add(nl())
37
+
38
+ flget = table()
39
+ flget.add("libdir", str(self.require_libdir()))
40
+
41
+ doc["flget"] = flget
42
+
43
+ with self._flget_config.open("w") as config_file:
44
+ dump(doc, config_file)
flget/dbsync.py ADDED
@@ -0,0 +1,379 @@
1
+ import dataclasses
2
+ import math
3
+ import shutil
4
+ import sqlite3
5
+ from collections.abc import Callable
6
+ from gzip import GzipFile
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from zipfile import ZipFile
10
+
11
+ import platformdirs
12
+ from markdownify import markdownify
13
+
14
+ from flget.mirror import AuthorEntry, BookEntry, Mirror, SeriesBookLink, SeriesEntry
15
+ from flget.mysqldump import load_dump
16
+
17
+ DB_FILE = [
18
+ "lib.libtranslator.sql.gz",
19
+ "lib.libavtor.sql.gz",
20
+ "lib.libavtorname.sql.gz",
21
+ "lib.libbook.sql.gz",
22
+ "lib.libfilename.sql.gz",
23
+ "lib.libgenre.sql.gz",
24
+ "lib.libgenrelist.sql.gz",
25
+ "lib.libjoinedbooks.sql.gz",
26
+ "lib.librate.sql.gz",
27
+ "lib.librecs.sql.gz",
28
+ "lib.libseqname.sql.gz",
29
+ "lib.libseq.sql.gz",
30
+ "lib.b.annotations.sql.gz",
31
+ "lib.b.annotations_pics.sql.gz",
32
+ "lib.reviews.sql.gz",
33
+ ]
34
+
35
+ ATTACHMENT_FILE = [
36
+ "lib.b.attached.zip",
37
+ ]
38
+
39
+ BOOKS_QUERY = """
40
+ select GROUP_CONCAT(distinct la.AvtorId) as authors,
41
+ GROUP_CONCAT(distinct concat_ws(' ', lan.firstname, lan.middlename, lan.lastname)) as author_names,
42
+ GROUP_CONCAT(distinct lgl.GenreCode) as genres,
43
+ lb.title as title,
44
+ lfn.filename as filename,
45
+ lb.FileSize as filesize,
46
+ lb.BookId as lib_id,
47
+ lb.FileType as ext,
48
+ substr(lb.Time, 1, 10) as year,
49
+ lb.Lang as lang,
50
+ COALESCE(CEIL(AVG(lr.Rate)), -1) as rating,
51
+ lb.keywords as keywords
52
+ from libbook as lb
53
+ left join libavtor as la on lb.BookId = la.BookId
54
+ left join libavtorname as lan on lan.AvtorId = la.AvtorId
55
+ left join libgenre as lg on lg.BookId = lb.BookId
56
+ left join libgenrelist as lgl on lgl.GenreId = lg.GenreId
57
+ left join librate as lr on lr.BookId = lb.BookId
58
+ left join libfilename as lfn on lfn.BookId = lb.BookId
59
+ group by title, filename, filesize, lib_id, ext, year, lang, keywords
60
+ """
61
+
62
+ AUTHORS_QUERY = """
63
+ select lan.AvtorId as id,
64
+ concat_ws(' ', lan.firstname, lan.middlename, lan.lastname) as name,
65
+ count(distinct la.BookId) as books,
66
+ count(distinct ls.SeqId) as series
67
+ from libavtorname as lan
68
+ left join libavtor la on lan.AvtorId = la.AvtorId
69
+ left join libseq ls on la.BookId = ls.BookId
70
+ group by id, name
71
+ """
72
+
73
+ SERIES_QUERY = """
74
+ select lsn.seqid as id, lsn.seqname as name, count(distinct ls.BookId) as books, count(distinct la.avtorid) as authors
75
+ from libseqname as lsn
76
+ left join libseq as ls on lsn.SeqId = ls.SeqId
77
+ left join libavtor la on ls.BookId = la.BookId
78
+ group by id, name
79
+ """
80
+
81
+ GENRE_TITLES_QUERY = """
82
+ SELECT GenreCode, GenreDesc FROM libgenrelist
83
+ """
84
+
85
+ SERIES_BOOKS_QUERY = """
86
+ select libseq.SeqId, libseq.BookId, SeqNumb, SeqName, group_concat(distinct AvtorId) as authors
87
+ from libseq
88
+ join libseqname on libseqname.SeqId = libseq.SeqId
89
+ join libavtor on libavtor.BookId = libseq.BookId
90
+ group by libseq.SeqId, libseq.BookId, SeqNumb, SeqName
91
+ """
92
+
93
+
94
+ # Indexes on the columns the four Mirror queries join on. Built once after
95
+ # import; without them the multi-table joins degrade to full scans. The dumps'
96
+ # own keys are not recreated -- only these matter for our queries.
97
+ _JOIN_INDEXES = [
98
+ ("libavtor", "BookId"),
99
+ ("libavtor", "AvtorId"),
100
+ ("libgenre", "BookId"),
101
+ ("libgenre", "GenreId"),
102
+ ("libgenrelist", "GenreId"),
103
+ ("libseq", "BookId"),
104
+ ("libseq", "SeqId"),
105
+ ("libseqname", "SeqId"),
106
+ ("librate", "BookId"),
107
+ ("libfilename", "BookId"),
108
+ ("libbook", "BookId"),
109
+ ("libavtorname", "AvtorId"),
110
+ ("libbannotations", "BookId"),
111
+ ("libreviews", "BookId"),
112
+ ("libbpics", "BookId"),
113
+ ]
114
+
115
+ ANNOTATIONS_QUERY = "SELECT BookId, Body FROM libbannotations ORDER BY BookId, nid"
116
+ REVIEWS_QUERY = "SELECT BookId, Name, Time, Text FROM libreviews ORDER BY BookId, Time"
117
+ PICS_QUERY = "SELECT BookId, File FROM libbpics ORDER BY BookId, nid"
118
+
119
+
120
+ def _db_path(cache_dir: str) -> Path:
121
+ return Path(cache_dir) / "flibusta.db"
122
+
123
+
124
+ def _html_to_markdown(html: str | None) -> str:
125
+ if not html:
126
+ return ""
127
+ return markdownify(html).strip()
128
+
129
+
130
+ def _concat_ws(sep: str, *args: Any) -> str:
131
+ """MySQL CONCAT_WS: join the non-NULL arguments with ``sep``."""
132
+ return sep.join(str(a) for a in args if a is not None)
133
+
134
+
135
+ def _ceil(value: Any) -> Any:
136
+ """MySQL CEIL, NULL-safe (so COALESCE around it still fires)."""
137
+ return None if value is None else math.ceil(value)
138
+
139
+
140
+ def _register_functions(con: sqlite3.Connection) -> None:
141
+ con.create_function("concat_ws", -1, _concat_ws)
142
+ con.create_function("ceil", 1, _ceil)
143
+
144
+
145
+ def import_dumps(source_dir: Path, progress_update: Callable[[float], None]) -> None:
146
+ flget_cache_dir = platformdirs.user_cache_dir("flget", ensure_exists=True)
147
+ all_files = DB_FILE + ATTACHMENT_FILE
148
+ progress = 100.0 / len(all_files)
149
+ for file in all_files:
150
+ shutil.copyfile(source_dir / file, Path(flget_cache_dir) / file)
151
+ progress_update(progress)
152
+
153
+
154
+ def decompress_dumps(progress_update: Callable[[float], None]) -> None:
155
+ flget_cache_dir = platformdirs.user_cache_dir("flget", ensure_exists=True)
156
+ progress = 100.0 / len(DB_FILE)
157
+ cache_dir = Path(flget_cache_dir)
158
+ for file in DB_FILE:
159
+ nongz_file = cache_dir / Path(file).stem
160
+ with (
161
+ GzipFile(cache_dir / file, "rb") as f_in,
162
+ nongz_file.open("wb") as f_out,
163
+ ):
164
+ shutil.copyfileobj(f_in, f_out)
165
+ progress_update(progress)
166
+
167
+
168
+ def db_import_dumps(progress_update: Callable[[float], None]) -> None:
169
+ flget_cache_dir = platformdirs.user_cache_dir("flget")
170
+ db_path = _db_path(flget_cache_dir)
171
+
172
+ # Rebuild the staging DB from scratch every sync -- it is a throwaway
173
+ # representation of the dumps, not durable data.
174
+ db_path.unlink(missing_ok=True)
175
+
176
+ con = sqlite3.connect(db_path)
177
+ # This is a one-shot bulk load into a disposable file, so trade durability
178
+ # for speed.
179
+ con.execute("PRAGMA journal_mode=OFF")
180
+ con.execute("PRAGMA synchronous=OFF")
181
+
182
+ progress = 100.0 / len(DB_FILE)
183
+ for file in DB_FILE:
184
+ fname = Path(flget_cache_dir) / Path(file).stem
185
+ load_dump(con, fname)
186
+ con.commit()
187
+ progress_update(progress)
188
+
189
+ for table, column in _JOIN_INDEXES:
190
+ con.execute(
191
+ f'CREATE INDEX IF NOT EXISTS "ix_{table}_{column}" ON "{table}"("{column}")'
192
+ )
193
+ con.commit()
194
+ con.close()
195
+
196
+
197
+ def _build_archives_map(lib_dir: str) -> dict[str, str]:
198
+ archives_map = {}
199
+ archive = [
200
+ f
201
+ for f in Path(lib_dir).iterdir()
202
+ if f.is_file() and f.name.lower().endswith(".zip")
203
+ ]
204
+ for a in archive:
205
+ with ZipFile(a, "r") as z:
206
+ for file in z.namelist():
207
+ archives_map[file] = a.name
208
+ archives_map[file.encode("utf-8").decode("cp437")] = a.name
209
+ return archives_map
210
+
211
+
212
+ def build_mirror( # noqa: PLR0912, PLR0915 - one long linear pass over the dump
213
+ lib_dir: str, progress_update: Callable[[float], None]
214
+ ) -> Mirror:
215
+ archives_map = _build_archives_map(lib_dir)
216
+ flget_cache_dir = platformdirs.user_cache_dir("flget")
217
+ con = sqlite3.connect(_db_path(flget_cache_dir))
218
+ _register_functions(con)
219
+
220
+ db_cursor = con.cursor()
221
+ db_cursor.execute("SELECT COUNT(*) FROM libbook")
222
+
223
+ total_books = db_cursor.fetchone()[0]
224
+ progress = 34.0 / total_books
225
+
226
+ db_cursor.execute(BOOKS_QUERY)
227
+ books = {}
228
+ for (
229
+ authors,
230
+ author_names,
231
+ genres,
232
+ title,
233
+ filename,
234
+ filesize,
235
+ libid,
236
+ ext,
237
+ year,
238
+ lang,
239
+ rating,
240
+ keywords,
241
+ ) in db_cursor:
242
+ progress_update(progress)
243
+
244
+ # usr archives have an actual name sometimes
245
+ original_name = False
246
+ member = filename
247
+ if ext.lower() == "fb2":
248
+ full_filename = f"{libid}.fb2"
249
+ if full_filename not in archives_map:
250
+ continue
251
+ archive = archives_map[full_filename]
252
+ member = str(libid)
253
+ elif filename:
254
+ if filename not in archives_map:
255
+ continue
256
+ archive = archives_map[filename]
257
+ original_name = True
258
+ else:
259
+ member = f"{libid}.{ext}"
260
+ if member not in archives_map:
261
+ member = f"{member}.zip"
262
+ if member not in archives_map:
263
+ continue
264
+ archive = archives_map[member]
265
+
266
+ book = BookEntry(
267
+ [int(a) for a in authors.split(",")] if authors else [],
268
+ author_names.split(",") if authors else [],
269
+ genres.split(",") if genres else [],
270
+ title,
271
+ [], # series placeholder, check SERIES_BOOKS_QUERY for detaiuls
272
+ member,
273
+ filesize,
274
+ libid,
275
+ False,
276
+ ext,
277
+ int(year[:4]) if year else None,
278
+ lang,
279
+ rating,
280
+ keywords.split(",") if keywords else [],
281
+ archive,
282
+ original_name,
283
+ )
284
+ books[str(libid)] = book
285
+
286
+ db_cursor.execute("SELECT COUNT(*) FROM libavtorname")
287
+
288
+ total_authors = db_cursor.fetchone()[0]
289
+ progress = 33.0 / total_authors
290
+
291
+ db_cursor.execute(AUTHORS_QUERY)
292
+ authors = []
293
+ for author_id, name, books_no, series_no in db_cursor:
294
+ progress_update(progress)
295
+ author = AuthorEntry(author_id, name, books_no, series_no)
296
+ authors.append(author)
297
+
298
+ db_cursor.execute("SELECT COUNT(*) FROM libseqname")
299
+
300
+ total_series = db_cursor.fetchone()[0]
301
+ progress = 33.0 / total_series
302
+
303
+ db_cursor.execute(SERIES_QUERY)
304
+ series = []
305
+ for series_id, name, books_no, authors_no in db_cursor:
306
+ progress_update(progress)
307
+ s = SeriesEntry(series_id, name, books_no, authors_no)
308
+ series.append(s)
309
+
310
+ db_cursor.execute(SERIES_BOOKS_QUERY)
311
+ series_index = []
312
+ book_series: dict[str, list[tuple[str, int]]] = {}
313
+ for series_id, book_id, serno, series_name, series_authors in db_cursor:
314
+ if str(book_id) in books:
315
+ book_series.setdefault(str(book_id), []).append((series_name, serno))
316
+ series_index.append(
317
+ SeriesBookLink(
318
+ series_id,
319
+ book_id,
320
+ serno,
321
+ series_name,
322
+ [int(a) for a in series_authors.split(",")],
323
+ )
324
+ )
325
+
326
+ genre_titles = {}
327
+ db_cursor.execute(GENRE_TITLES_QUERY)
328
+ for code, desc in db_cursor:
329
+ if code and desc:
330
+ genre_titles[code.lower()] = desc
331
+
332
+ # Pre-render annotations/reviews to Markdown and attach to the books
333
+ annotation_md: dict[str, str] = {}
334
+ db_cursor.execute(ANNOTATIONS_QUERY)
335
+ for book_id, body in db_cursor:
336
+ key = str(book_id)
337
+ if key not in books:
338
+ continue
339
+ md = _html_to_markdown(body)
340
+ if not md:
341
+ continue
342
+ annotation_md[key] = (
343
+ f"{annotation_md[key]}\n\n{md}" if key in annotation_md else md
344
+ )
345
+
346
+ review_md: dict[str, str] = {}
347
+ db_cursor.execute(REVIEWS_QUERY)
348
+ for book_id, name, time, text in db_cursor:
349
+ key = str(book_id)
350
+ if key not in books:
351
+ continue
352
+ body = _html_to_markdown(text)
353
+ if not body:
354
+ continue
355
+ block = f"**{name or 'Anonymous'}** — _{time}_\n\n{body}\n\n---\n"
356
+ review_md[key] = review_md[key] + block if key in review_md else block
357
+
358
+ cover_map: dict[str, str] = {}
359
+ db_cursor.execute(PICS_QUERY)
360
+ for book_id, file in db_cursor:
361
+ key = str(book_id)
362
+ if key not in books or not file or key in cover_map:
363
+ continue
364
+ cover_map[key] = file
365
+
366
+ for key in (
367
+ annotation_md.keys() | review_md.keys() | cover_map.keys() | book_series.keys()
368
+ ):
369
+ books[key] = dataclasses.replace(
370
+ books[key],
371
+ series=book_series.get(key, []),
372
+ annotation=annotation_md.get(key, ""),
373
+ reviews=review_md.get(key, ""),
374
+ cover=cover_map.get(key, ""),
375
+ )
376
+
377
+ con.close()
378
+
379
+ return Mirror(books, series, authors, series_index, genre_titles=genre_titles)
flget/filters.py ADDED
@@ -0,0 +1,164 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from dataclasses import dataclass
3
+
4
+ import typer
5
+
6
+ from flget.mirror import BookEntry
7
+
8
+ # Static ISO-639-1 code -> English name map for the languages commonly found in
9
+ # Flibusta. The DB dumps carry no language-name table, so titles on the
10
+ # `filters languages` page come from here. Codes absent from this map render a
11
+ # blank title.
12
+ LANGUAGE_NAMES: dict[str, str] = {
13
+ "ru": "Russian",
14
+ "en": "English",
15
+ "uk": "Ukrainian",
16
+ "be": "Belarusian",
17
+ "de": "German",
18
+ "fr": "French",
19
+ "es": "Spanish",
20
+ "it": "Italian",
21
+ "pl": "Polish",
22
+ "pt": "Portuguese",
23
+ "nl": "Dutch",
24
+ "sv": "Swedish",
25
+ "no": "Norwegian",
26
+ "da": "Danish",
27
+ "fi": "Finnish",
28
+ "cs": "Czech",
29
+ "sk": "Slovak",
30
+ "bg": "Bulgarian",
31
+ "sr": "Serbian",
32
+ "hr": "Croatian",
33
+ "sl": "Slovenian",
34
+ "ro": "Romanian",
35
+ "hu": "Hungarian",
36
+ "el": "Greek",
37
+ "tr": "Turkish",
38
+ "lt": "Lithuanian",
39
+ "lv": "Latvian",
40
+ "et": "Estonian",
41
+ "ka": "Georgian",
42
+ "hy": "Armenian",
43
+ "az": "Azerbaijani",
44
+ "kk": "Kazakh",
45
+ "uz": "Uzbek",
46
+ "tg": "Tajik",
47
+ "he": "Hebrew",
48
+ "ar": "Arabic",
49
+ "fa": "Persian",
50
+ "zh": "Chinese",
51
+ "ja": "Japanese",
52
+ "ko": "Korean",
53
+ "la": "Latin",
54
+ }
55
+
56
+
57
+ def language_display(lang: str) -> str:
58
+ """Render a language code for display: ``Russian (ru)`` when a human-readable
59
+ name is known, the bare code (no brackets) when it is not, and empty for an
60
+ empty code."""
61
+ if not lang:
62
+ return ""
63
+ name = LANGUAGE_NAMES.get(lang.lower())
64
+ return f"{name} ({lang})" if name else lang
65
+
66
+
67
+ def genre_display_names(genres: Iterable[str], titles: Mapping[str, str]) -> list[str]:
68
+ """Map genre codes to their human-readable titles, falling back to the raw
69
+ code when no title is known. ``titles`` keys are lowercased codes (see the
70
+ ``genre_titles`` build in ``dbsync``)."""
71
+ return [titles.get(g.lower(), g) for g in genres]
72
+
73
+
74
+ _SIZE_UNITS = {
75
+ "": 1,
76
+ "B": 1,
77
+ "K": 1024,
78
+ "KB": 1024,
79
+ "M": 1024**2,
80
+ "MB": 1024**2,
81
+ "G": 1024**3,
82
+ "GB": 1024**3,
83
+ }
84
+
85
+
86
+ def parse_size(text: str) -> int:
87
+ """Parse a human size (``500K``, ``2M``, ``1.5G``, ``KB/MB/GB`` or plain bytes)
88
+ into a byte count. Units are base 1024. Raises ``typer.BadParameter`` on
89
+ malformed input."""
90
+ raw = text.strip()
91
+ if not raw:
92
+ raise typer.BadParameter(f"Invalid size: '{text}'")
93
+
94
+ upper = raw.upper()
95
+ unit = ""
96
+ for suffix in ("KB", "MB", "GB", "B", "K", "M", "G"):
97
+ if upper.endswith(suffix):
98
+ unit = suffix
99
+ upper = upper[: -len(suffix)]
100
+ break
101
+
102
+ number = upper.strip()
103
+ try:
104
+ value = float(number)
105
+ except ValueError:
106
+ raise typer.BadParameter(f"Invalid size: '{text}'") from None
107
+ if value < 0:
108
+ raise typer.BadParameter(f"Invalid size: '{text}'")
109
+
110
+ return int(value * _SIZE_UNITS[unit])
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class BookFilter:
115
+ genres: frozenset[str] = frozenset()
116
+ formats: frozenset[str] = frozenset()
117
+ languages: frozenset[str] = frozenset()
118
+ year_from: int | None = None
119
+ year_to: int | None = None
120
+ min_size: int | None = None
121
+ max_size: int | None = None
122
+
123
+ def is_empty(self) -> bool:
124
+ return not (
125
+ self.genres
126
+ or self.formats
127
+ or self.languages
128
+ or self.year_from is not None
129
+ or self.year_to is not None
130
+ or self.min_size is not None
131
+ or self.max_size is not None
132
+ )
133
+
134
+ def matches(self, book: BookEntry) -> bool: # noqa: PLR0911 - guard chain
135
+ if self.genres:
136
+ book_genres = {g.lower() for g in book.genres}
137
+ if not book_genres & self.genres:
138
+ return False
139
+
140
+ if self.formats and (book.ext or "").lower() not in self.formats:
141
+ return False
142
+
143
+ if self.languages and book.lang.lower() not in self.languages:
144
+ return False
145
+
146
+ if self.year_from is not None or self.year_to is not None:
147
+ year = book.year
148
+ if year is None:
149
+ return False
150
+ if self.year_from is not None and year < self.year_from:
151
+ return False
152
+ if self.year_to is not None and year > self.year_to:
153
+ return False
154
+
155
+ if self.min_size is not None and (
156
+ book.filesize is None or book.filesize < self.min_size
157
+ ):
158
+ return False
159
+ if self.max_size is not None and ( # noqa: SIM103 - guard-chain style
160
+ book.filesize is None or book.filesize > self.max_size
161
+ ):
162
+ return False
163
+
164
+ return True