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 +0 -0
- flget/config.py +44 -0
- flget/dbsync.py +379 -0
- flget/filters.py +164 -0
- flget/flget.py +499 -0
- flget/fltui.py +240 -0
- flget/fltui.tcss +168 -0
- flget/index.py +270 -0
- flget/inpx.py +166 -0
- flget/library.py +613 -0
- flget/mirror.py +84 -0
- flget/mysqldump.py +236 -0
- flget/naming.py +67 -0
- flget/sync.py +191 -0
- flget/tui_authors_tab.py +115 -0
- flget/tui_book_table.py +122 -0
- flget/tui_book_view.py +118 -0
- flget/tui_filter_screen.py +151 -0
- flget/tui_save_screen.py +53 -0
- flget/tui_series_tab.py +54 -0
- flget-0.1.0.dist-info/METADATA +317 -0
- flget-0.1.0.dist-info/RECORD +25 -0
- flget-0.1.0.dist-info/WHEEL +4 -0
- flget-0.1.0.dist-info/entry_points.txt +4 -0
- flget-0.1.0.dist-info/licenses/LICENSE +674 -0
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
|