cquarry 1.9.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.
- cquarry/__init__.py +2 -0
- cquarry/analytics.py +142 -0
- cquarry/config.py +40 -0
- cquarry/db.py +1527 -0
- cquarry/helpers.py +370 -0
- cquarry/integrity.py +174 -0
- cquarry/search.py +1046 -0
- cquarry/write.py +1174 -0
- cquarry-1.9.0.dist-info/METADATA +151 -0
- cquarry-1.9.0.dist-info/RECORD +12 -0
- cquarry-1.9.0.dist-info/WHEEL +4 -0
- cquarry-1.9.0.dist-info/licenses/LICENSE +21 -0
cquarry/__init__.py
ADDED
cquarry/analytics.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Library analytics derivations, promoted from CalibreQuarry's frontend.
|
|
2
|
+
|
|
3
|
+
Reading pace, per-author stats, and wing overlap were computed inside
|
|
4
|
+
CalibreQuarry's ``--analytics`` mode from pure database data; Hermitage's
|
|
5
|
+
stats views and Carrel's collection-pace views would each have re-derived
|
|
6
|
+
them. This module is the shared derivation layer — the frontend keeps
|
|
7
|
+
formatting. Anything an existing cquarry API already serves
|
|
8
|
+
(:meth:`get_format_stats`, :meth:`get_entities`, :meth:`get_tag_counts`)
|
|
9
|
+
deliberately does not appear here.
|
|
10
|
+
|
|
11
|
+
Same shape as :mod:`cquarry.integrity`: pure functions over the cached rows,
|
|
12
|
+
no SQL of their own.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from cquarry.helpers import calibre_rating_to_stars, normalize_author_display
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from cquarry.db import CalibreDB
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"addition_timeline",
|
|
27
|
+
"author_stats",
|
|
28
|
+
"rating_distribution",
|
|
29
|
+
"vl_overlap",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def addition_timeline(db: CalibreDB, granularity: str = "month") -> dict[str, int]:
|
|
34
|
+
"""Books added per calendar bucket, chronological.
|
|
35
|
+
|
|
36
|
+
``granularity="month"`` (the default) keys ``"YYYY-MM"``; ``"year"``
|
|
37
|
+
keys ``"YYYY"``. Books with no timestamp are skipped — they have no
|
|
38
|
+
bucket to land in. Anything else raises ValueError.
|
|
39
|
+
"""
|
|
40
|
+
if granularity not in ("month", "year"):
|
|
41
|
+
raise ValueError(f"granularity must be 'month' or 'year', not {granularity!r}")
|
|
42
|
+
width = 7 if granularity == "month" else 4
|
|
43
|
+
out: dict[str, int] = {}
|
|
44
|
+
for b in db.get_all_books():
|
|
45
|
+
ts = b["timestamp"] or ""
|
|
46
|
+
if not ts:
|
|
47
|
+
continue
|
|
48
|
+
key = ts[:width]
|
|
49
|
+
if not key[0].isdigit():
|
|
50
|
+
continue
|
|
51
|
+
out[key] = out.get(key, 0) + 1
|
|
52
|
+
return dict(sorted(out.items()))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def author_stats(db: CalibreDB) -> list[dict[str, Any]]:
|
|
56
|
+
"""Per-primary-author rollups, count-descending then name.
|
|
57
|
+
|
|
58
|
+
Each entry: ``author`` (primary author via
|
|
59
|
+
:func:`cquarry.helpers.normalize_author_display`'s ``primary_only``
|
|
60
|
+
mode), ``book_count``, ``avg_rating`` (star-scale average over rated
|
|
61
|
+
books only; ``0.0`` when the author has none), ``rated_count`` (how many
|
|
62
|
+
of the author's books carry a rating — renderers need it for
|
|
63
|
+
"(N rated)" and the average alone cannot give it back), and ``formats``
|
|
64
|
+
(sorted distinct format names across the author's books). Authorless
|
|
65
|
+
books are skipped — they have no author to roll up into.
|
|
66
|
+
"""
|
|
67
|
+
data: dict[str, dict[str, Any]] = {}
|
|
68
|
+
for b in db.get_all_books():
|
|
69
|
+
if not b["authors"]:
|
|
70
|
+
continue
|
|
71
|
+
author = normalize_author_display(b["authors"], primary_only=True)
|
|
72
|
+
ad = data.setdefault(author, {"count": 0, "ratings": [], "formats": set()})
|
|
73
|
+
ad["count"] += 1
|
|
74
|
+
stars = calibre_rating_to_stars(b["rating"])
|
|
75
|
+
if stars is not None:
|
|
76
|
+
ad["ratings"].append(stars)
|
|
77
|
+
if b["formats"]:
|
|
78
|
+
ad["formats"].update(f.strip() for f in b["formats"])
|
|
79
|
+
|
|
80
|
+
out = []
|
|
81
|
+
for author, ad in sorted(data.items(), key=lambda x: (-x[1]["count"], x[0])):
|
|
82
|
+
ratings = ad["ratings"]
|
|
83
|
+
out.append(
|
|
84
|
+
{
|
|
85
|
+
"author": author,
|
|
86
|
+
"book_count": ad["count"],
|
|
87
|
+
"avg_rating": sum(ratings) / len(ratings) if ratings else 0.0,
|
|
88
|
+
"rated_count": len(ratings),
|
|
89
|
+
"formats": sorted(ad["formats"]),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rating_distribution(db: CalibreDB) -> dict[float | str, int]:
|
|
96
|
+
"""How many books sit at each star rating, ascending, ``"unrated"`` last.
|
|
97
|
+
|
|
98
|
+
Keys are star floats on the half-step scale Calibre's 0-10 integers map
|
|
99
|
+
to (``normalize_rating``); books with no rating (``None``/``0``) count
|
|
100
|
+
under the ``"unrated"`` string key.
|
|
101
|
+
"""
|
|
102
|
+
out: dict[float | str, int] = {}
|
|
103
|
+
for b in db.get_all_books():
|
|
104
|
+
stars = calibre_rating_to_stars(b["rating"])
|
|
105
|
+
key: float | str = stars if stars is not None else "unrated"
|
|
106
|
+
out[key] = out.get(key, 0) + 1
|
|
107
|
+
floats = sorted(k for k in out if isinstance(k, float))
|
|
108
|
+
ordered: dict[float | str, int] = {k: out[k] for k in floats}
|
|
109
|
+
if "unrated" in out:
|
|
110
|
+
ordered["unrated"] = out["unrated"]
|
|
111
|
+
return ordered
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def vl_overlap(
|
|
115
|
+
db: CalibreDB, names: list[str] | tuple[str, ...] | None = None
|
|
116
|
+
) -> dict[tuple[str, ...], list[int]]:
|
|
117
|
+
"""Books shared by two or more virtual libraries (wings).
|
|
118
|
+
|
|
119
|
+
Returns ``{(wing, wing, ...): [ids]}`` for every combination of two or
|
|
120
|
+
more wings that actually overlaps, wing names sorted inside each key.
|
|
121
|
+
``names`` restricts the wings considered (unknown names raise through
|
|
122
|
+
:meth:`CalibreDB.resolve_vl`); None means every virtual library. Books
|
|
123
|
+
in exactly one wing appear nowhere — single-wing membership is not
|
|
124
|
+
overlap.
|
|
125
|
+
"""
|
|
126
|
+
if names is None:
|
|
127
|
+
names = sorted(db.get_virtual_libraries())
|
|
128
|
+
ids_by_wing: dict[str, set[int]] = {n: db.resolve_vl(n) for n in names}
|
|
129
|
+
|
|
130
|
+
wings_of: dict[int, list[str]] = {}
|
|
131
|
+
for wing, ids in ids_by_wing.items():
|
|
132
|
+
for book_id in ids:
|
|
133
|
+
wings_of.setdefault(book_id, []).append(wing)
|
|
134
|
+
|
|
135
|
+
out: dict[tuple[str, ...], list[int]] = {}
|
|
136
|
+
for book_id, wings in wings_of.items():
|
|
137
|
+
if len(wings) < 2:
|
|
138
|
+
continue
|
|
139
|
+
for r in range(2, len(wings) + 1):
|
|
140
|
+
for combo in itertools.combinations(sorted(wings), r):
|
|
141
|
+
out.setdefault(combo, []).append(book_id)
|
|
142
|
+
return {k: sorted(v) for k, v in sorted(out.items())}
|
cquarry/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
VERSION = "1.9.0"
|
|
5
|
+
|
|
6
|
+
DEFAULT_DB_PATHS = [
|
|
7
|
+
"metadata.db",
|
|
8
|
+
os.path.expanduser("~/Calibre Library/metadata.db"),
|
|
9
|
+
os.path.expanduser("~/calibre/metadata.db"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
CALIBRE_RATING_SCALE = 2 # Calibre stores rating * 2 (so 5 stars = 10)
|
|
13
|
+
|
|
14
|
+
CONFIG_FILE = os.path.expanduser("~/.config/cquarry/config.json")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
if os.path.exists(CONFIG_FILE):
|
|
19
|
+
try:
|
|
20
|
+
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(config: dict) -> None:
|
|
28
|
+
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
|
|
29
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
30
|
+
json.dump(config, f, indent=4)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_db_path() -> str | None:
|
|
34
|
+
return load_config().get("db_path")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_db_path(path: str) -> None:
|
|
38
|
+
config = load_config()
|
|
39
|
+
config["db_path"] = os.path.abspath(os.path.expanduser(path))
|
|
40
|
+
save_config(config)
|