1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import os
7from collections import OrderedDict, defaultdict
8from threading import RLock as Lock
9
10from calibre import filesystem_encoding
11from calibre.db.cache import Cache
12from calibre.db.legacy import LibraryDatabase, create_backend, set_global_state
13from calibre.utils.filenames import samefile as _samefile
14from calibre.utils.monotonic import monotonic
15from polyglot.builtins import iteritems, itervalues
16
17
18def gui_on_db_event(event_type, library_id, event_data):
19    from calibre.gui2.ui import get_gui
20    gui = get_gui()
21    if gui is not None:
22        gui.library_broker.on_db_event(event_type, library_id, event_data)
23
24
25def canonicalize_path(p):
26    if isinstance(p, bytes):
27        p = p.decode(filesystem_encoding)
28    p = os.path.abspath(p).replace(os.sep, '/').rstrip('/')
29    return os.path.normcase(p)
30
31
32def samefile(a, b):
33    a, b = canonicalize_path(a), canonicalize_path(b)
34    if a == b:
35        return True
36    return _samefile(a, b)
37
38
39def basename(path):
40    while path and path[-1] in ('/' + os.sep):
41        path = path[:-1]
42    ans = os.path.basename(path)
43    if not ans:
44        # Can happen for a path like D:\ on windows
45        if len(path) == 2 and path[1] == ':':
46            ans = path[0]
47    return ans or 'Library'
48
49
50def init_library(library_path, is_default_library):
51    db = Cache(
52        create_backend(
53            library_path, load_user_formatter_functions=is_default_library))
54    db.init()
55    return db
56
57
58def make_library_id_unique(library_id, existing):
59    bname = library_id
60    c = 0
61    while library_id in existing:
62        c += 1
63        library_id = bname + ('%d' % c)
64    return library_id
65
66
67def library_id_from_path(path, existing=frozenset()):
68    library_id = basename(path).replace(' ', '_')
69    return make_library_id_unique(library_id, existing)
70
71
72def correct_case_of_last_path_component(original_path):
73    original_path = os.path.abspath(original_path)
74    prefix, basename = os.path.split(original_path)
75    q = basename.lower()
76    try:
77        equals = tuple(x for x in os.listdir(prefix) if x.lower() == q)
78    except OSError:
79        equals = ()
80    if len(equals) > 1:
81        if basename not in equals:
82            basename = equals[0]
83    elif equals:
84        basename = equals[0]
85    return os.path.join(prefix, basename)
86
87
88def db_matches(db, library_id, library_path):
89    db = db.new_api
90    if getattr(db, 'server_library_id', object()) == library_id:
91        return True
92    dbpath = db.dbpath
93    return samefile(dbpath, os.path.join(library_path, os.path.basename(dbpath)))
94
95
96class LibraryBroker:
97
98    def __init__(self, libraries):
99        self.lock = Lock()
100        self.lmap = OrderedDict()
101        self.library_name_map = {}
102        self.original_path_map = {}
103        seen = set()
104        for original_path in libraries:
105            path = canonicalize_path(original_path)
106            if path in seen:
107                continue
108            is_samefile = False
109            for s in seen:
110                if samefile(s, path):
111                    is_samefile = True
112                    break
113            seen.add(path)
114            if is_samefile or not LibraryDatabase.exists_at(path):
115                continue
116            corrected_path = correct_case_of_last_path_component(original_path)
117            library_id = library_id_from_path(corrected_path, self.lmap)
118            self.lmap[library_id] = path
119            self.library_name_map[library_id] = basename(corrected_path)
120            self.original_path_map[path] = original_path
121        self.loaded_dbs = {}
122        self.category_caches, self.search_caches, self.tag_browser_caches = (
123            defaultdict(OrderedDict), defaultdict(OrderedDict),
124            defaultdict(OrderedDict))
125
126    def get(self, library_id=None):
127        with self:
128            library_id = library_id or self.default_library
129            if library_id in self.loaded_dbs:
130                return self.loaded_dbs[library_id]
131            path = self.lmap.get(library_id)
132            if path is None:
133                return
134            try:
135                self.loaded_dbs[library_id] = ans = self.init_library(
136                    path, library_id == self.default_library)
137                ans.new_api.server_library_id = library_id
138            except Exception:
139                self.loaded_dbs[library_id] = None
140                raise
141            return ans
142
143    def init_library(self, library_path, is_default_library):
144        library_path = self.original_path_map.get(library_path, library_path)
145        return init_library(library_path, is_default_library)
146
147    def close(self):
148        with self:
149            for db in itervalues(self.loaded_dbs):
150                getattr(db, 'close', lambda: None)()
151            self.lmap, self.loaded_dbs = OrderedDict(), {}
152
153    @property
154    def default_library(self):
155        return next(iter(self.lmap))
156
157    @property
158    def library_map(self):
159        with self:
160            return self.library_name_map.copy()
161
162    def allowed_libraries(self, filter_func):
163        with self:
164            allowed_names = filter_func(
165                basename(l) for l in itervalues(self.lmap))
166            return OrderedDict(((lid, self.library_map[lid])
167                                for lid, path in iteritems(self.lmap)
168                                if basename(path) in allowed_names))
169
170    def path_for_library_id(self, library_id):
171        with self:
172            lpath = self.lmap.get(library_id)
173            if lpath is None:
174                q = library_id.lower()
175                for k, v in self.lmap.items():
176                    if k.lower() == q:
177                        lpath = v
178                        break
179                else:
180                    return
181            return self.original_path_map.get(lpath)
182
183    def __enter__(self):
184        self.lock.acquire()
185
186    def __exit__(self, *a):
187        self.lock.release()
188
189
190EXPIRED_AGE = 300  # seconds
191
192
193def load_gui_libraries(gprefs=None):
194    if gprefs is None:
195        from calibre.utils.config import JSONConfig
196        gprefs = JSONConfig('gui')
197    stats = gprefs.get('library_usage_stats', {})
198    return sorted(stats, key=stats.get, reverse=True)
199
200
201def path_for_db(db):
202    return db.new_api.backend.library_path
203
204
205class GuiLibraryBroker(LibraryBroker):
206
207    def __init__(self, db):
208        from calibre.gui2 import gprefs
209        self.last_used_times = defaultdict(lambda: -EXPIRED_AGE)
210        self.gui_library_id = None
211        self.listening_for_db_events = False
212        LibraryBroker.__init__(self, load_gui_libraries(gprefs))
213        self.gui_library_changed(db)
214
215    def init_library(self, library_path, is_default_library):
216        library_path = self.original_path_map.get(library_path, library_path)
217        db = LibraryDatabase(library_path, is_second_db=True)
218        if self.listening_for_db_events:
219            db.new_api.add_listener(gui_on_db_event)
220        return db
221
222    def get(self, library_id=None):
223        try:
224            return getattr(LibraryBroker.get(self, library_id), 'new_api', None)
225        finally:
226            self.last_used_times[library_id or self.default_library] = monotonic()
227
228    def start_listening_for_db_events(self):
229        with self:
230            self.listening_for_db_events = True
231            for db in self.loaded_dbs.values():
232                db.new_api.add_listener(gui_on_db_event)
233
234    def on_db_event(self, event_type, library_id, event_data):
235        from calibre.gui2.ui import get_gui
236        gui = get_gui()
237        if gui is not None:
238            with self:
239                db = self.loaded_dbs.get(library_id)
240            if db is not None:
241                gui.event_in_db.emit(db, event_type, event_data)
242
243    def get_library(self, original_library_path):
244        library_path = canonicalize_path(original_library_path)
245        with self:
246            for library_id, path in iteritems(self.lmap):
247                if samefile(library_path, path):
248                    db = self.loaded_dbs.get(library_id)
249                    if db is None:
250                        db = self.loaded_dbs[library_id] = self.init_library(
251                            path, False)
252                    db.new_api.server_library_id = library_id
253                    return db
254            # A new library
255            if library_path not in self.original_path_map:
256                self.original_path_map[library_path] = original_library_path
257            db = self.init_library(library_path, False)
258            corrected_path = correct_case_of_last_path_component(original_library_path)
259            library_id = library_id_from_path(corrected_path, self.lmap)
260            db.new_api.server_library_id = library_id
261            self.lmap[library_id] = library_path
262            self.library_name_map[library_id] = basename(corrected_path)
263            self.loaded_dbs[library_id] = db
264            return db
265
266    def prepare_for_gui_library_change(self, newloc):
267        # Must be called with lock held
268        for library_id, path in iteritems(self.lmap):
269            db = self.loaded_dbs.get(library_id)
270            if db is not None and samefile(newloc, path):
271                if library_id == self.gui_library_id:
272                    # Have to reload db
273                    self.loaded_dbs.pop(library_id, None)
274                    return
275                set_global_state(db)
276                return db
277
278    def gui_library_changed(self, db, olddb=None):
279        # Must be called with lock held
280        original_path = path_for_db(db)
281        newloc = canonicalize_path(original_path)
282        for library_id, path in iteritems(self.lmap):
283            if samefile(newloc, path):
284                self.loaded_dbs[library_id] = db
285                self.gui_library_id = library_id
286                break
287        else:
288            # A new library
289            corrected_path = correct_case_of_last_path_component(original_path)
290            library_id = self.gui_library_id = library_id_from_path(corrected_path, self.lmap)
291            self.lmap[library_id] = newloc
292            self.library_name_map[library_id] = basename(corrected_path)
293            self.original_path_map[newloc] = original_path
294            self.loaded_dbs[library_id] = db
295        db.new_api.server_library_id = library_id
296        if self.listening_for_db_events:
297            db.new_api.add_listener(gui_on_db_event)
298        if olddb is not None and samefile(path_for_db(olddb), path_for_db(db)):
299            # This happens after a restore database, for example
300            olddb.close(), olddb.break_cycles()
301        self._prune_loaded_dbs()
302
303    def is_gui_library(self, library_path):
304        with self:
305            if self.gui_library_id and self.gui_library_id in self.lmap:
306                return samefile(library_path, self.lmap[self.gui_library_id])
307            return False
308
309    def _prune_loaded_dbs(self):
310        now = monotonic()
311        for library_id in tuple(self.loaded_dbs):
312            if library_id != self.gui_library_id and now - self.last_used_times[
313                library_id] > EXPIRED_AGE:
314                db = self.loaded_dbs.pop(library_id, None)
315                if db is not None:
316                    db.close()
317                    db.break_cycles()
318
319    def prune_loaded_dbs(self):
320        with self:
321            self._prune_loaded_dbs()
322
323    def unload_library(self, library_path):
324        with self:
325            path = canonicalize_path(library_path)
326            for library_id, q in iteritems(self.lmap):
327                if samefile(path, q):
328                    break
329            else:
330                return
331            db = self.loaded_dbs.pop(library_id, None)
332            if db is not None:
333                db.close()
334                db.break_cycles()
335
336    def remove_library(self, path):
337        with self:
338            path = canonicalize_path(path)
339            for library_id, q in iteritems(self.lmap):
340                if samefile(path, q):
341                    break
342            else:
343                return
344            self.lmap.pop(library_id, None), self.library_name_map.pop(
345                library_id, None), self.original_path_map.pop(path, None)
346            db = self.loaded_dbs.pop(library_id, None)
347            if db is not None:
348                db.close()
349                db.break_cycles()
350