1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
#!/usr/bin/env python3
"""Search the Calibre library by metadata and print matching document paths.
Usage: search.py KEYWORD [KEYWORD...] all keywords must match (AND)
search.py --any KEYWORD [KEYWORD...] any keyword matches (OR)
Matches title, authors, tags, series, publisher, description and stored
filename, and prints the exact path to read, best match first.
Exit 0 matched, 1 nothing matched, 2 bad usage or no library.
"""
import glob
import os
import sqlite3
import sys
import unicodedata
import urllib.parse
LIB = os.path.realpath(os.path.expanduser(os.environ.get("CALIBRE_LIBRARY") or "~/Documents/calibre-library"))
DB = os.path.join(LIB, "metadata.db")
LIMIT = 40
QUERY = """
SELECT b.id, b.title, b.path,
(SELECT group_concat(a.name, ', ') FROM authors a
JOIN books_authors_link l ON l.author = a.id WHERE l.book = b.id),
(SELECT group_concat(t.name, ', ') FROM tags t
JOIN books_tags_link l ON l.tag = t.id WHERE l.book = b.id),
(SELECT group_concat(s.name, ', ') FROM series s
JOIN books_series_link l ON l.series = s.id WHERE l.book = b.id),
(SELECT group_concat(p.name, ', ') FROM publishers p
JOIN books_publishers_link l ON l.publisher = p.id WHERE l.book = b.id),
(SELECT c.text FROM comments c WHERE c.book = b.id),
(SELECT group_concat(d.format || '/' || d.name, char(10)) FROM data d WHERE d.book = b.id)
FROM books b
"""
_authors = None
def norm(s):
# NFKC + casefold so MICRO SIGN/GREEK MU, curly quotes and dashes compare equal.
return unicodedata.normalize("NFKC", s).casefold()
def resolve(bid, path, fmt, name):
"""Absolute path of one format row, or None if the file is not on disk.
Calibre renames `<author>/<title> (<id>)` when metadata is edited and leaves
the old directory behind, so on a miss retry by the stable book id.
"""
ext = "." + fmt.lower()
exact = os.path.join(LIB, path, name + ext)
if os.path.exists(exact):
return exact
global _authors
if _authors is None:
_authors = {}
for d in os.listdir(LIB): # case-only duplicates exist on a case-sensitive mount
_authors.setdefault(d.lower(), []).append(d)
for author in _authors.get(path.split("/")[0].lower(), ()):
for d in glob.glob(os.path.join(glob.escape(os.path.join(LIB, author)), "* (%d)" % bid)):
for f in sorted(glob.glob(os.path.join(glob.escape(d), "*" + ext))):
return f
return None
def main(argv):
match_any = "--any" in argv
keywords = [norm(k) for k in argv if k != "--any"]
if not keywords:
print(__doc__, file=sys.stderr)
return 2
if not os.path.exists(DB):
print(f"no Calibre database at {DB}", file=sys.stderr)
return 2
db = sqlite3.connect("file:" + urllib.parse.quote(DB) + "?mode=ro", uri=True)
hits = []
for bid, title, path, authors, tags, series, publisher, comments, files in db.execute(QUERY):
entries = [e.split("/", 1) for e in (files or "").split("\n") if e]
hay = norm(" ".join(x for x in (title, authors, tags, series, publisher, comments) if x)
+ " " + " ".join(n for _, n in entries))
found = sum(k in hay for k in keywords)
if not found or (not match_any and found < len(keywords)):
continue
in_title = sum(k in norm(title) for k in keywords)
hits.append((-found, -in_title, title, authors, tags, bid, path, entries))
if not hits:
print("no match")
return 1
hits.sort(key=lambda h: h[:3]) # authors/tags may be None and are not comparable
print(f"{len(hits)} book(s)" + (f", showing the {LIMIT} best" if len(hits) > LIMIT else ""))
for _, _, title, authors, tags, bid, path, entries in hits[:LIMIT]:
print(f"\n{title}" + (f" [{authors}]" if authors else "") + (f" tags: {tags}" if tags else ""))
if not entries:
print(" (no file in this library)")
for fmt, name in entries:
p = resolve(bid, path, fmt, name)
print(f" {fmt} {p}" if p else f" {fmt} MISSING (library mid-sync or file deleted)")
return 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except BrokenPipeError:
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
sys.exit(0)
|